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:
@@ -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
@@ -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, "<")
|
||||
}
|
||||
|
||||
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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user