1
0
mirror of https://github.com/alexandrev/xslt-lab.git synced 2026-09-13 08:43:16 +00:00

new updates

This commit is contained in:
2026-01-12 17:26:49 +01:00
parent 36e2c286f4
commit 37e22ac26e
7 changed files with 294 additions and 46 deletions
+68
View File
@@ -0,0 +1,68 @@
name: "Idea / Bug report"
description: Share an idea, bug, or improvement for xslt-lab.
labels:
- triage
body:
- type: textarea
id: summary
attributes:
label: Summary
description: What happened or what would you like to see?
placeholder: Clear, one-paragraph overview.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps or example
description: Provide reproduction steps or an example transform that shows the issue/idea.
placeholder: |
1. Go to...
2. Run this XSLT...
3. See...
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected result
placeholder: What did you expect to happen?
validations:
required: false
- type: textarea
id: actual
attributes:
label: Actual result
placeholder: What happened instead?
validations:
required: false
- type: textarea
id: xslt
attributes:
label: XSLT snippet (if relevant)
render: xml
placeholder: |
<xsl:stylesheet version="2.0" ...>
...
</xsl:stylesheet>
validations:
required: false
- type: textarea
id: input
attributes:
label: Sample input (if relevant)
render: xml
placeholder: |
<root>
...
</root>
validations:
required: false
- type: textarea
id: environment
attributes:
label: Environment
description: Browser/OS, frontend build version, backend version, and any feature flags.
placeholder: macOS 14 / Chrome 121 / frontend v0.x / backend v0.x / trace on/off
validations:
required: false
+26 -1
View File
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"io/ioutil"
"log"
"net/http"
@@ -59,6 +60,26 @@ type Transformation struct {
CreatedAt time.Time `json:"created_at"`
}
func pickSourceXML(params map[string]string) (string, string) {
looksLikeXML := func(s string) bool {
trimmed := strings.TrimSpace(s)
return strings.HasPrefix(trimmed, "<") || strings.HasPrefix(trimmed, "&lt;")
}
preferred := []string{"input", "source", "xml", "document", "input1"}
for _, key := range preferred {
if val, ok := params[key]; ok && looksLikeXML(val) {
return html.UnescapeString(strings.TrimSpace(val)), key
}
}
for key, val := range params {
if looksLikeXML(val) {
return html.UnescapeString(strings.TrimSpace(val)), key
}
}
return "<root/>", ""
}
func loadConfig(filename string) (*AppConfig, error) {
data, err := os.ReadFile(filename)
if err != nil {
@@ -181,11 +202,15 @@ func main() {
return
}
if err := os.WriteFile(inputPath, []byte("<root/>"), 0644); err != nil {
sourceXML, sourceKey := pickSourceXML(req.Parameters)
if err := os.WriteFile(inputPath, []byte(sourceXML), 0644); err != nil {
log.Printf("write input failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot write input"})
return
}
if sourceKey != "" {
log.Printf("using parameter %q as source document", sourceKey)
}
argsPath := filepath.Join(tmpDir, "args")
var cmdArgs []string
+106 -34
View File
@@ -80,9 +80,22 @@ function defaultWorkspaceStatus() {
traceEntries: [],
traceText: "",
showRawTrace: false,
resultView: "source",
};
}
function looksLikeHtml(text) {
if (!text || typeof text !== "string") return false;
const trimmed = text.trim();
if (!trimmed.startsWith("<")) return false;
const lowered = trimmed.slice(0, 200).toLowerCase();
return (
lowered.startsWith("<!doctype html") ||
/^<html\b/.test(lowered) ||
/^<body\b/.test(lowered)
);
}
function normalizeWorkspaceImport(payload) {
if (!payload || typeof payload !== "object") {
throw new Error("Workspace file is empty or invalid.");
@@ -350,12 +363,15 @@ export default function App() {
traceEntries,
traceText,
showRawTrace,
resultView,
} = activeStatus;
const MAX_ERROR_LINES = 3;
const limitedErrorLines = (errorLines || []).slice(0, MAX_ERROR_LINES);
const hasHiddenErrors = (errorLines || []).length > MAX_ERROR_LINES;
const canCopyErrors = Boolean((errorLines && errorLines.length) || error);
const showResultPane = !error;
const canRenderHtml = useMemo(() => looksLikeHtml(result), [result]);
const effectiveResultView = canRenderHtml ? resultView || "source" : "source";
const TRACE_NAME_LIMIT = 80;
const TRACE_VALUE_LIMIT = 200;
const EMPTY_SYMBOL = "(empty)";
@@ -563,6 +579,16 @@ export default function App() {
});
}, []);
useEffect(() => {
if (!activeTab) return;
if (!canRenderHtml && resultView === "render") {
updateWorkspaceStatus(activeTab.id, (prev) => ({
...prev,
resultView: "source",
}));
}
}, [activeTab, canRenderHtml, resultView, updateWorkspaceStatus]);
const truncateText = useCallback((text, limit) => {
if (!text) {
return "";
@@ -837,16 +863,19 @@ export default function App() {
traceEntries: [],
traceText: "",
showRawTrace: false,
resultView: "source",
});
return;
}
const data = await res.json();
const defaultView = looksLikeHtml(data.result) ? "render" : "source";
updateWorkspaceStatus(tabId, {
result: data.result,
duration: data.duration_ms,
error: "",
errorLines: [],
showRawTrace: false,
resultView: defaultView,
});
const newEntries = traceEnabled ? (data.trace || []) : [];
updateWorkspaceStatus(tabId, (prev) => ({
@@ -867,6 +896,7 @@ export default function App() {
traceEntries: [],
traceText: "",
showRawTrace: false,
resultView: "source",
});
}
}, 500);
@@ -1616,45 +1646,87 @@ export default function App() {
{duration !== null && (
<div className="success-box">Success in {duration} ms</div>
)}
<button
className="icon-button result-format-button"
onClick={() => {
try {
const formatted = formatXML(result);
if (activeTab) {
<div className="result-actions">
{canRenderHtml && (
<button
type="button"
className={`icon-button result-view-toggle${effectiveResultView === "render" ? " active" : ""}`}
onClick={() => {
if (!activeTab) return;
const next = effectiveResultView === "render" ? "source" : "render";
updateWorkspaceStatus(activeTab.id, (prev) => ({
...prev,
result: formatted,
resultView: next,
}));
}}
title={
effectiveResultView === "render"
? "Show source instead of rendered HTML"
: "Render HTML output"
}
} catch {}
}}
>
📝
</button>
<button
type="button"
className={`icon-button result-reset-button${isCustomResultHeight ? " active" : ""}`}
onClick={handleResetResultHeight}
title="Reset result pane height"
aria-label="Reset result pane height"
>
</button>
<div className="result-editor-wrap">
<Editor
height="100%"
language="xml"
value={result}
onMount={(editor) => (resultEditorRef.current = editor)}
options={{
readOnly: true,
minimap: { enabled: false },
automaticLayout: true,
wordWrap: "bounded",
wordWrapBreakAfterCharacters: ' \t})]?|>'
aria-label={
effectiveResultView === "render"
? "Show source instead of rendered HTML"
: "Render HTML output"
}
>
{effectiveResultView === "render" ? "🧾" : "🌐"}
</button>
)}
<button
className="icon-button result-format-button"
disabled={effectiveResultView !== "source"}
onClick={() => {
if (effectiveResultView !== "source") return;
try {
const formatted = formatXML(result);
if (activeTab) {
updateWorkspaceStatus(activeTab.id, (prev) => ({
...prev,
result: formatted,
}));
}
} catch {}
}}
/>
title="Format result as pretty XML"
aria-label="Format result as pretty XML"
>
📝
</button>
<button
type="button"
className={`icon-button result-reset-button${isCustomResultHeight ? " active" : ""}`}
onClick={handleResetResultHeight}
title="Reset result pane height"
aria-label="Reset result pane height"
>
</button>
</div>
<div className="result-editor-wrap">
{effectiveResultView === "render" && canRenderHtml ? (
<div className="result-render">
<iframe
title="Rendered HTML output"
srcDoc={result || "<!-- empty -->"}
sandbox=""
/>
</div>
) : (
<Editor
height="100%"
language="xml"
value={result}
onMount={(editor) => (resultEditorRef.current = editor)}
options={{
readOnly: true,
minimap: { enabled: false },
automaticLayout: true,
wordWrap: "bounded",
wordWrapBreakAfterCharacters: ' \t})]?|>'
}}
/>
)}
</div>
</>
)}
@@ -12,6 +12,8 @@ const STORAGE_KEY = "feedbackPos";
const MIN_MARGIN = 10;
const DEFAULT_MARGIN = 24;
const FALLBACK_WIDTH = 220;
const ISSUE_URL =
"https://github.com/alexandrev/xslt-lab/issues/new?template=idea.yml";
export default function FeedbackWidget() {
const [collapsed, setCollapsed] = useState(() => {
@@ -242,6 +244,14 @@ export default function FeedbackWidget() {
<a className="feedback-link" href={mailLink}>
Send feedback
</a>
<a
className="feedback-link"
href={ISSUE_URL}
target="_blank"
rel="noopener noreferrer"
>
🐞 Crear issue en GitHub
</a>
</div>
)}
</div>
+31 -5
View File
@@ -36,10 +36,18 @@ export function stripParamBlock(text) {
if (after.startsWith("\n")) after = after.slice(1);
result = before + after;
}
return result.replace(
/[ \t]*<xsl:param\b[^>]*?(?:\/>|>[\s\S]*?<\/xsl:param>)[ \t]*(?:\r?\n)?/g,
"",
const stylesheetOpen = result.match(/<xsl:stylesheet[^>]*>/);
if (!stylesheetOpen) return result;
const headEnd = stylesheetOpen.index + stylesheetOpen[0].length;
const tail = result.slice(headEnd);
const leadingParams = tail.match(
/^[\r\n\t ]*(?:<xsl:param\b[^>]*?(?:\/>|>[\s\S]*?<\/xsl:param>)[\r\n\t ]*)+/,
);
if (!leadingParams) return result;
return result.slice(0, headEnd) + tail.slice(leadingParams[0].length);
}
export function getParamBlock(text) {
@@ -65,11 +73,29 @@ export function injectParamBlock(text, params) {
}
export function extractParamNames(text) {
const clean = getParamBlock(text);
const block = getParamBlock(text);
if (block !== text) {
return collectParamNames(block);
}
const stylesheetOpen = text.match(/<xsl:stylesheet[^>]*>/);
if (!stylesheetOpen) return [];
const headEnd = stylesheetOpen.index + stylesheetOpen[0].length;
const tail = text.slice(headEnd);
const leadingParams = tail.match(
/^[\r\n\t ]*(?:<xsl:param\b[^>]*?(?:\/>|>[\s\S]*?<\/xsl:param>)[\r\n\t ]*)+/,
);
if (!leadingParams) return [];
return collectParamNames(leadingParams[0]);
}
function collectParamNames(fragment) {
const names = new Set();
const regex = /<xsl:param[^>]*name="([^"]+)"[^>]*>/g;
let m;
while ((m = regex.exec(clean))) {
while ((m = regex.exec(fragment))) {
names.add(m[1]);
}
return Array.from(names);
+19
View File
@@ -34,10 +34,29 @@ describe("workspace utils", () => {
expect(cleaned).not.toMatch(/<xsl:param/);
});
it("keeps scoped params inside functions", () => {
const withFunctionParam = `<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:f="urn:test">
<xsl:template match="/"><out/></xsl:template>
<xsl:function name="f:echo" as="xs:string">
<xsl:param name="val"/>
<xsl:sequence select="$val"/>
</xsl:function>
</xsl:stylesheet>`;
const cleaned = stripParamBlock(withFunctionParam);
expect(cleaned).toContain(`<xsl:param name="val"/>`);
});
it("extracts params from existing definitions", () => {
expect(extractParamNames(SAMPLE_STYLESHEET).sort()).toEqual(["bar", "foo"]);
});
it("ignores template-scoped params", () => {
const withLocalParam = `<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template name="local"><xsl:param name="expr"/><xsl:value-of select="$expr"/></xsl:template>
</xsl:stylesheet>`;
expect(extractParamNames(withLocalParam)).toEqual([]);
});
it("adds new params discovered in xslt", () => {
const tab = {
params: [{ name: "foo", value: "value", open: false }],
+34 -6
View File
@@ -751,6 +751,22 @@ body,
min-height: 0;
}
.result-render {
height: 100%;
border: 1px solid #cfdcf4;
border-radius: 12px;
overflow: hidden;
background: #fff;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04);
}
.result-render iframe {
border: 0;
width: 100%;
height: 100%;
background: #fff;
}
.result-resizer {
flex: 0 0 auto;
height: 0.75rem;
@@ -853,18 +869,26 @@ body,
align-items: center;
}
.result-format-button,
.result-reset-button {
.result-actions {
position: absolute;
top: 0.25rem;
top: 0.35rem;
right: 0.5rem;
display: flex;
gap: 0.35rem;
}
.result-format-button {
right: 0.5rem;
.result-format-button,
.result-reset-button,
.result-view-toggle {
position: static;
}
.result-format-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-reset-button {
right: 3rem;
transition: color 0.2s ease;
}
@@ -872,6 +896,10 @@ body,
color: #007acc;
}
.result-view-toggle.active {
color: #21426c;
}
.feedback-widget {
position: fixed;
z-index: 1000;