Skip to content

Commit f23e998

Browse files
committed
feat: wrap copydeck placeholders in HTML at render time
Wrap copydeck placeholders in semantic HTML spans at render time Placeholder values (name, loc, codeLine) are now HTML-escaped and wrapped in typed spans (pfem__var, pfem__line/pfem__file, pfem__code) when building the HTML output. Removes all inline styling / <i> tags.
1 parent d3a967c commit f23e998

4 files changed

Lines changed: 79 additions & 14 deletions

File tree

copydecks/en/copydeck.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
"not_message": ["is not defined"]
2424
},
2525
"title": "This variable doesn't exist yet",
26-
"summary": "Your code uses the variable \"{{name}}\", but it hasn't been created yet. Check {{loc}}. If you meant to print the text <i>{{name}}</i>, put it in double quotes.",
27-
"why": "Without speech marks Python treats <i>{{name}}</i> as a variable, and this variable does not exist yet.",
26+
"summary": "Your code uses the variable {{name}}, but it hasn't been created yet. Check {{loc}}. If you meant to print the text {{name}}, put it in double quotes.",
27+
"why": "Without speech marks Python treats {{name}} as a variable, and this variable does not exist yet.",
2828
"steps": [
2929
"If it is meant to be text, put speech marks around {{name}}.",
3030
"If it is meant to be a variable, make it first (for example: {{name}} = 0).",
@@ -36,7 +36,7 @@
3636
"match_message": ["is not defined"]
3737
},
3838
"title": "This variable doesn't exist here",
39-
"summary": "\"{{name}}\" might be created somewhere else, but you're using it at {{loc}}. If you meant the text <i>{{name}}</i>, put it in double quotes.",
39+
"summary": "{{name}} might be created somewhere else, but you're using it at {{loc}}. If you meant the text {{name}}, put it in double quotes.",
4040
"why": "A variable created in another place might not be available here.",
4141
"steps": [
4242
"Move the line that makes it to above where you use it.",
@@ -50,7 +50,7 @@
5050
"variants": [
5151
{
5252
"title": "Variable used before it gets a value in this part of the code",
53-
"summary": "Here, \"{{name}}\" is used at {{loc}} before you give it a value.",
53+
"summary": "Here, {{name}} is used at {{loc}} before you give it a value.",
5454
"why": "You have used the variable before it has been given a value. If used within a subroutine, the variable must either be global and given a value outside the subroutine definition, or local and given a value inside the subroutine, before it is used. ",
5555
"steps": [
5656
"Give it a value first (add a line like {{name}} = ... before you use it)."

src/engine.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,32 @@ const pickVariant = (trace: Trace, code: string | undefined) => {
7070
codeLine: codeLine
7171
};
7272

73+
const linePart = trace.line ? `<span class="pfem__line">${escapeHtml(lineStr)} ${escapeHtml(String(trace.line))}</span>` : null;
74+
const filePart = trace.file ? `<span class="pfem__file">${escapeHtml(trace.file)}</span>` : null;
75+
const htmlLoc =
76+
linePart && filePart ? `${linePart} ${escapeHtml(inStr)} ${filePart}` :
77+
linePart ?? filePart ?? escapeHtml(thisFileStr);
78+
79+
const htmlTransforms: Record<string, (v: string) => string> = {
80+
name: (v) => `<span class="pfem__var">${escapeHtml(v)}</span>`,
81+
loc: (_) => htmlLoc,
82+
codeLine: (v) => `<span class="pfem__code">${escapeHtml(v)}</span>`,
83+
};
84+
7385
for (let i = 0; i < entry.variants.length; i++) {
7486
const v = entry.variants[i];
7587
if (!matches(v)) continue;
7688

77-
const title = tmpl(v.title, vars);
89+
const title = tmpl(v.title, vars);
7890
const summary = tmpl(v.summary, vars);
79-
const why = v.why ? tmpl(v.why, vars) : undefined;
80-
const steps = v.steps?.map((s) => tmpl(s, vars));
81-
const badges = v.badges;
91+
const why = v.why ? tmpl(v.why, vars) : undefined;
92+
const steps = v.steps?.map((s) => tmpl(s, vars));
93+
const badges = v.badges;
94+
95+
const titleHtml = tmpl(v.title, vars, htmlTransforms);
96+
const summaryHtml = tmpl(v.summary, vars, htmlTransforms);
97+
const whyHtml = v.why ? tmpl(v.why, vars, htmlTransforms) : undefined;
98+
const stepsHtml = v.steps?.map((s) => tmpl(s, vars, htmlTransforms));
8299

83100
let patch: string | undefined = undefined;
84101
if (trace.type === "AttributeError" && /\.push\s*\(/i.test(codeLine)) {
@@ -95,10 +112,10 @@ const pickVariant = (trace: Trace, code: string | undefined) => {
95112
}
96113

97114
const html = [
98-
`<div class="pfem__title">${escapeHtml(title)}</div>`,
99-
`<div class="pfem__summary">${summary}</div>`,
100-
why ? `<div class="pfem__why">${why}</div>` : "",
101-
steps?.length ? `<ul class="pfem__steps">${steps.map((s) => `<li>${s}</li>`).join("")}</ul>` : "",
115+
`<div class="pfem__title">${titleHtml}</div>`,
116+
`<div class="pfem__summary">${summaryHtml}</div>`,
117+
whyHtml ? `<div class="pfem__why">${whyHtml}</div>` : "",
118+
stepsHtml?.length ? `<ul class="pfem__steps">${stepsHtml.map((s) => `<li>${s}</li>`).join("")}</ul>` : "",
102119
patch ? `<pre class="pfem__patch">${escapeHtml(patch)}</pre>` : "",
103120
`<details class="pfem__details"><summary>${escapeHtml(getUiString("originalError", "Original error"))}</summary><pre>${escapeHtml(
104121
(trace.type || getUiString("error", "Error")) + ": " + trace.message

src/utils.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
export const escapeHtml = (s: string) =>
22
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
33

4-
export const tmpl = (s: string, vars: Record<string, string>) =>
5-
(s || "").replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, k) => (k in vars ? String(vars[k]) : ""));
4+
export const tmpl = (
5+
s: string,
6+
vars: Record<string, string>,
7+
transforms?: Record<string, (v: string) => string>
8+
) =>
9+
(s || "").replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, k) => {
10+
if (!(k in vars)) return "";
11+
const v = String(vars[k]);
12+
return transforms?.[k] ? transforms[k](v) : v;
13+
});
614

715
export const safeRegexTest = (pattern: string, input: string) => {
816
try {

tests/engine.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,44 @@ AttributeError: 'list' object has no attribute 'push'`;
107107
expect(res.trace.type).toBe("AttributeError");
108108
expect(res.patch).toContain(".append(");
109109
});
110+
111+
it("plain result fields contain no HTML tags", () => {
112+
const code = `print("Hello")\nprint(kittens)\n`;
113+
const raw = `Traceback (most recent call last):
114+
File "main.py", line 2, in <module>
115+
NameError: name 'kittens' is not defined`;
116+
const res = friendlyExplain({ error: raw, code, runtime: "skulpt" });
117+
expect(res.summary).not.toMatch(/<[^>]+>/);
118+
expect(res.why).not.toMatch(/<[^>]+>/);
119+
res.steps?.forEach((s) => expect(s).not.toMatch(/<[^>]+>/));
120+
});
121+
122+
it("html output wraps {{name}} in pfem__var span", () => {
123+
const code = `print("Hello")\nprint(kittens)\n`;
124+
const raw = `Traceback (most recent call last):
125+
File "main.py", line 2, in <module>
126+
NameError: name 'kittens' is not defined`;
127+
const res = friendlyExplain({ error: raw, code, runtime: "skulpt" });
128+
expect(res.html).toContain('<span class="pfem__var">kittens</span>');
129+
});
130+
131+
it("html output wraps {{loc}} line and file in separate spans", () => {
132+
const code = `print("Hello")\nprint(kittens)\n`;
133+
const raw = `Traceback (most recent call last):
134+
File "main.py", line 2, in <module>
135+
NameError: name 'kittens' is not defined`;
136+
const res = friendlyExplain({ error: raw, code, runtime: "skulpt" });
137+
expect(res.html).toContain('<span class="pfem__line">');
138+
expect(res.html).toContain('<span class="pfem__file">main.py</span>');
139+
});
140+
141+
it("escapes HTML in codeLine within html output", () => {
142+
const code = `for i in range(3)<script>alert(1)</script>\n print(i)`;
143+
const raw = `Traceback (most recent call last):
144+
File "main.py", line 1
145+
SyntaxError: invalid syntax`;
146+
const res = friendlyExplain({ error: raw, code, runtime: "skulpt" });
147+
expect(res.html).not.toContain("<script>");
148+
expect(res.html).toContain("&lt;script&gt;");
149+
});
110150
});

0 commit comments

Comments
 (0)