Skip to content

Commit 7dab69c

Browse files
committed
feat: accessible output HTML for friendlyExplain
Restructure of `result.html` output with WCAG 2.1 AA in mind
1 parent 20853ba commit 7dab69c

5 files changed

Lines changed: 68 additions & 30 deletions

File tree

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
# Python Friendly Error Messages
22

3-
Todo:
4-
- Accessibility of output HTML
5-
63
A small library that explains Python error messages in a friendlier way, inspired by [p5.js's Friendly Error System](https://p5js.org/contribute/friendly_error_system/).
74

85
It can be used in browser-based editors (like RPF's [Code Editor web component](https://github.com/RaspberryPiFoundation/editor-ui)) or any environment that executes Python code through Pyodide or Skulpt.
@@ -69,6 +66,23 @@ const result = friendlyExplain({
6966

7067
See the [demo](docs/README.md) for a full set of examples.
7168

69+
## Accessibility
70+
71+
`result.html` is built to be accessible by default (with WCAG 2.1 AA in mind):
72+
73+
- The whole explanation is one labelled group — `<div class="pfem" role="group" lang="…" aria-labelledby="…">`, named by its title, with `lang` taken from the copydeck so screen readers pronounce localised copy correctly (`3.1.2 Language of Parts`). `role="group"` (not a landmark) keeps things uncluttered when several explanations render on one page
74+
- The title is deliberately not a heading. Heading level depends on the surrounding page outline, which a library can't know, so the title supplies the group's accessible name instead. If you want it in your heading outline, render your own heading from `result.title` and use `result.html` (or the structured fields) for the body
75+
- Code is marked up as code; inline tokens use `<code>` and blocks use `<pre><code>`
76+
- The suggested fix has a visible "Suggested fix" label; the original traceback stays in a native `<details>`/`<summary>`
77+
- Element ids are randomised per call so `aria-labelledby` remains unambiguous when multiple explanations coexist on a page
78+
79+
### Your responsibilities
80+
81+
A couple of WCAG 2.1AA requirements can only be met by the host app:
82+
83+
- Announce it: the explanation appears in response to running code. For a screen reader to announce it without stealing focus, insert it into a pre-existing live region (`aria-live="polite"` / `role="status"`) that is already in the DOM, or move focus to it
84+
- Contrast & colour: all styling is yours, ensure text contrast, and don't rely on colours (`.pfem__var`, `.pfem__file`, …) alone to convey meaning
85+
7286
## Development
7387

7488
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed instructions.

copydecks/en/copydeck.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
"thisFile": "this file",
2929
"originalError": "Original error",
3030
"error": "Error",
31-
"copydeckNotLoaded": "Copydeck not loaded"
31+
"copydeckNotLoaded": "Copydeck not loaded",
32+
"suggestedFix": "Suggested fix"
3233
},
3334
"errors": {
3435
"NameError": {

src/engine.ts

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ const getUiString = (key: keyof NonNullable<CopyDeck["ui"]>, fallback: string):
1414
return state.copy?.ui?.[key] || fallback;
1515
};
1616

17+
// Short non-cryptographic id, unique enough to keep aria-labelledby references distinct when several explanations are rendered on the same page
18+
const uniqueId = () => `pfem-${Math.random().toString(36).slice(2, 8)}`;
19+
1720
export const registerAdapter = (name: string, fn: (raw: string, code?: string) => Trace | null) =>
1821
(state.adapters[name] = fn);
1922

@@ -31,11 +34,9 @@ const coerceTrace = (input: string | Error | Trace, code?: string, runtime?: str
3134

3235
const raw = typeof input === "string" ? input : String((input as Error).stack || (input as Error).message || input);
3336
const parsed = adapter(raw, code);
34-
// The error could not be parsed into a structured trace, so there is no friendly
35-
// explanation to offer. Return null and let the caller fall back to the raw error.
37+
// The error could not be parsed into a structured trace, so there is no friendly explanation to offer. Return null and let the caller fall back to the raw error
3638
if (!parsed) return null;
37-
// The runtime-agnostic adapter leaves `runtime: "unknown"`; this adds the concrete
38-
// runtime we dispatched on so the trace carries the correct label
39+
// The runtime-agnostic adapter leaves `runtime: "unknown"`; this adds the concrete runtime we dispatched on so the trace carries the correct label
3940
parsed.runtime = runtime as Runtime;
4041
return parsed;
4142
};
@@ -74,15 +75,15 @@ const pickVariant = (trace: Trace, code: string | undefined, sections?: Section[
7475
};
7576

7677
const linePart = trace.line ? `<span class="pfem__line">${escapeHtml(lineStr)} ${escapeHtml(String(trace.line))}</span>` : null;
77-
const filePart = trace.file ? `<span class="pfem__file">${escapeHtml(trace.file)}</span>` : null;
78+
const filePart = trace.file ? `<code class="pfem__file">${escapeHtml(trace.file)}</code>` : null;
7879
const htmlLoc =
7980
linePart && filePart ? `${linePart} ${escapeHtml(inStr)} ${filePart}` :
8081
linePart ?? filePart ?? escapeHtml(thisFileStr);
8182

8283
const htmlTransforms: Record<string, (v: string) => string> = {
83-
name: (v) => `<span class="pfem__var">${escapeHtml(v)}</span>`,
84+
name: (v) => `<code class="pfem__var">${escapeHtml(v)}</code>`,
8485
loc: (_) => htmlLoc,
85-
codeLine: (v) => `<span class="pfem__code">${escapeHtml(v)}</span>`,
86+
codeLine: (v) => `<code class="pfem__code">${escapeHtml(v)}</code>`,
8687
};
8788

8889
for (let i = 0; i < entry.variants.length; i++) {
@@ -115,17 +116,23 @@ const pickVariant = (trace: Trace, code: string | undefined, sections?: Section[
115116
}
116117

117118
const has = (s: Section) => !sections || sections.includes(s);
118-
const html = [
119-
has("title") ? `<div class="pfem__title">${titleHtml}</div>` : "",
120-
has("summary") ? `<div class="pfem__summary">${summaryHtml}</div>` : "",
121-
has("why") && whyHtml ? `<div class="pfem__why">${whyHtml}</div>` : "",
119+
const id = uniqueId();
120+
const lang = deck?.meta.language ?? "en";
121+
const inner = [
122+
has("title") ? `<p class="pfem__title" id="${id}-title">${titleHtml}</p>` : "",
123+
has("summary") ? `<p class="pfem__summary">${summaryHtml}</p>` : "",
124+
has("why") && whyHtml ? `<p class="pfem__why">${whyHtml}</p>` : "",
122125
has("steps") && stepsHtml?.length ? `<ul class="pfem__steps">${stepsHtml.map((s) => `<li>${s}</li>`).join("")}</ul>` : "",
123-
has("patch") && patch ? `<pre class="pfem__patch">${escapeHtml(patch)}</pre>` : "",
124-
has("details") ? `<details class="pfem__details"><summary>${escapeHtml(getUiString("originalError", "Original error"))}</summary><pre>${escapeHtml(
126+
has("patch") && patch ? `<div class="pfem__patch"><p class="pfem__patch-label">${escapeHtml(getUiString("suggestedFix", "Suggested fix"))}</p><pre class="pfem__patch-code"><code>${escapeHtml(patch)}</code></pre></div>` : "",
127+
has("details") ? `<details class="pfem__details"><summary>${escapeHtml(getUiString("originalError", "Original error"))}</summary><pre><code>${escapeHtml(
125128
(trace.type || getUiString("error", "Error")) + ": " + trace.message
126-
)}</pre></details>` : "",
129+
)}</code></pre></details>` : "",
127130
].filter(Boolean).join("\n");
128131

132+
// Wrap in a single labelled group so a screen reader perceives one explanation as a named unit
133+
const labelledBy = has("title") ? ` aria-labelledby="${id}-title"` : "";
134+
const html = `<div class="pfem" role="group" lang="${escapeHtml(lang)}"${labelledBy}>\n${inner}\n</div>`;
135+
129136
return {
130137
variantId: `${kind}/variants/${i}`,
131138
title,
@@ -145,17 +152,15 @@ export const friendlyExplain = (opts: ExplainOptions): ExplainResult | null => {
145152
const code = opts.code;
146153

147154
const trace = coerceTrace(opts.error, code, opts.runtime);
148-
// The error could not be parsed — no friendly explanation; caller uses the raw error.
155+
// The error could not be parsed — no friendly explanation; caller uses the raw error
149156
if (!trace) return null;
150157

151158
// Caller-provided file/line take precedence over whatever was parsed from the trace
152-
// Useful when the traceback's innermost frame references an internal file (eg.
153-
// Pyodide's "<exec>") instead of the user's filename, or when the line needs correcting.
159+
// Useful when the traceback's innermost frame references an internal file (eg. Pyodide's "<exec>") instead of the user's filename, or when the line needs correcting
154160
if (opts.file !== undefined) trace.file = opts.file;
155161
if (opts.line !== undefined) trace.line = opts.line;
156162

157-
// (Re)derive the code context from the effective line: when the caller overrides the
158-
// line, or when a pre-parsed trace arrived without a codeLine.
163+
// (Re)derive the code context from the effective line: when the caller overrides the line, or when a pre-parsed trace arrived without a codeLine
159164
if (code && trace.line && (opts.line !== undefined || !trace.codeLine)) {
160165
const lines = code.split(/\r?\n/);
161166
trace.codeLine = lines[trace.line - 1]?.trim();
@@ -164,8 +169,7 @@ export const friendlyExplain = (opts: ExplainOptions): ExplainResult | null => {
164169
}
165170

166171
const chosen = pickVariant(trace, code, opts.sections);
167-
// No copydeck entry/variant matched this error. Return null so the caller can fall
168-
// back to showing the raw Python/Pyodide error as-is, rather than synthetic copy.
172+
// No copydeck entry/variant matched this error. Return null so the caller can fall back to showing the raw Python/Pyodide error as-is
169173
if (!chosen) return null;
170174

171175
return { trace, ...chosen };

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export type CopyDeck = {
7171
originalError?: string;
7272
error?: string;
7373
copydeckNotLoaded?: string;
74+
suggestedFix?: string;
7475
};
7576
};
7677

tests/engine.test.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,23 +141,41 @@ NameError: name 'kittens' is not defined`;
141141
res.steps?.forEach((s) => expect(s).not.toMatch(/<[^>]+>/));
142142
});
143143

144-
it("html output wraps {{name}} in pfem__var span", () => {
144+
it("html output wraps {{name}} in a pfem__var <code> element", () => {
145145
const code = `print("Hello")\nprint(kittens)\n`;
146146
const raw = `Traceback (most recent call last):
147147
File "main.py", line 2, in <module>
148148
NameError: name 'kittens' is not defined`;
149149
const res = friendlyExplain({ error: raw, code, runtime: "skulpt" });
150-
expect(res.html).toContain('<span class="pfem__var">kittens</span>');
150+
expect(res!.html).toContain('<code class="pfem__var">kittens</code>');
151151
});
152152

153-
it("html output wraps {{loc}} line and file in separate spans", () => {
153+
it("html output wraps {{loc}} line (span) and file (code)", () => {
154154
const code = `print("Hello")\nprint(kittens)\n`;
155155
const raw = `Traceback (most recent call last):
156156
File "main.py", line 2, in <module>
157157
NameError: name 'kittens' is not defined`;
158158
const res = friendlyExplain({ error: raw, code, runtime: "skulpt" });
159-
expect(res.html).toContain('<span class="pfem__line">');
160-
expect(res.html).toContain('<span class="pfem__file">main.py</span>');
159+
expect(res!.html).toContain('<span class="pfem__line">');
160+
expect(res!.html).toContain('<code class="pfem__file">main.py</code>');
161+
});
162+
163+
it("wraps output in a labelled group with a unique id and lang", () => {
164+
const code = `print("Hello")\nprint(kittens)\n`;
165+
const raw = `Traceback (most recent call last):
166+
File "main.py", line 2, in <module>
167+
NameError: name 'kittens' is not defined`;
168+
const a = friendlyExplain({ error: raw, code, runtime: "skulpt" })!;
169+
const b = friendlyExplain({ error: raw, code, runtime: "skulpt" })!;
170+
171+
// group wrapper with lang and an aria-labelledby pointing at the title's id
172+
expect(a.html).toMatch(/^<div class="pfem" role="group" lang="en" aria-labelledby="pfem-[a-z0-9]+-title">/);
173+
const titleId = a.html!.match(/aria-labelledby="(pfem-[a-z0-9]+-title)"/)![1];
174+
expect(a.html).toContain(`<p class="pfem__title" id="${titleId}">`);
175+
176+
// ids differ between calls so multiple explanations on a page don't collide
177+
const idOf = (h: string) => h.match(/aria-labelledby="(pfem-[a-z0-9]+-title)"/)![1];
178+
expect(idOf(a.html!)).not.toBe(idOf(b.html!));
161179
});
162180

163181
it("sections option limits html output to specified sections", () => {

0 commit comments

Comments
 (0)