@reticular/speakable 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/dist/cli.js +257 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +406 -1
- package/dist/index.js +2194 -5
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +265 -19
- package/dist/mcp.js.map +1 -1
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Speakable
|
|
2
2
|
|
|
3
|
-
Simulate how NVDA, JAWS, and
|
|
3
|
+
Simulate how NVDA, JAWS, VoiceOver, and Windows Narrator interpret your HTML — from a single CLI tool. Catch screen reader issues early, detect regressions in CI/CD, and reduce manual testing overhead.
|
|
4
4
|
|
|
5
5
|
**Try it in your browser — no install needed:** [getspeakable.dev/tool](https://getspeakable.dev/tool)
|
|
6
6
|
|
|
@@ -43,7 +43,7 @@ cat page.html | speakable -
|
|
|
43
43
|
|
|
44
44
|
## Web Analyzer
|
|
45
45
|
|
|
46
|
-
If you want to test HTML quickly without installing anything, paste it directly into the web analyzer at [getspeakable.dev/tool](https://getspeakable.dev/tool). It runs the same simulation engine in the browser — supports NVDA, JAWS, and
|
|
46
|
+
If you want to test HTML quickly without installing anything, paste it directly into the web analyzer at [getspeakable.dev/tool](https://getspeakable.dev/tool). It runs the same simulation engine in the browser — supports NVDA, JAWS, VoiceOver, and Narrator output, CSS selector filtering, and diff mode. Useful for quick iteration, debugging a specific component, or sharing results with teammates who don't have the CLI installed.
|
|
47
47
|
|
|
48
48
|
## CLI Reference
|
|
49
49
|
|
|
@@ -83,9 +83,10 @@ Options:
|
|
|
83
83
|
| NVDA | `-s nvda` | Windows |
|
|
84
84
|
| JAWS | `-s jaws` | Windows |
|
|
85
85
|
| VoiceOver | `-s voiceover` | macOS |
|
|
86
|
-
|
|
|
86
|
+
| Narrator | `-s narrator` | Windows |
|
|
87
|
+
| All | `-s all` | All four readers side by side |
|
|
87
88
|
|
|
88
|
-
Each renderer produces heuristic output approximating the real screen reader's behavior. Key differences are preserved — for example, NVDA says "navigation landmark" while JAWS says "navigation region" and
|
|
89
|
+
Each renderer produces heuristic output approximating the real screen reader's behavior. Key differences are preserved — for example, NVDA says "navigation landmark" while JAWS says "navigation region", VoiceOver says "navigation", and Narrator says "navigation".
|
|
89
90
|
|
|
90
91
|
## Features
|
|
91
92
|
|
|
@@ -182,7 +183,7 @@ Add to your MCP config (Kiro, VS Code, Cursor, Claude Desktop, Windsurf):
|
|
|
182
183
|
|
|
183
184
|
| Tool | Description |
|
|
184
185
|
|------|-------------|
|
|
185
|
-
| `analyze_html` | Predict screen reader output for NVDA, JAWS, and
|
|
186
|
+
| `analyze_html` | Predict screen reader output for NVDA, JAWS, VoiceOver, and Narrator |
|
|
186
187
|
| `audit_html` | Generate accessibility audit report with issues and remediation |
|
|
187
188
|
| `diff_html` | Compare two HTML versions for accessibility regressions |
|
|
188
189
|
|
package/dist/cli.js
CHANGED
|
@@ -22,7 +22,7 @@ function validateOptions(rawOptions) {
|
|
|
22
22
|
`Invalid format: ${format}. Must be one of: ${validFormats.join(", ")}`
|
|
23
23
|
);
|
|
24
24
|
}
|
|
25
|
-
const validReaders = ["nvda", "jaws", "voiceover", "all"];
|
|
25
|
+
const validReaders = ["nvda", "jaws", "voiceover", "narrator", "all"];
|
|
26
26
|
if (!validReaders.includes(screenReader)) {
|
|
27
27
|
throw new Error(
|
|
28
28
|
`Invalid screen reader: ${screenReader}. Must be one of: ${validReaders.join(", ")}`
|
|
@@ -240,8 +240,15 @@ var ParsingError = class extends Error {
|
|
|
240
240
|
this.name = "ParsingError";
|
|
241
241
|
}
|
|
242
242
|
};
|
|
243
|
+
var MAX_INPUT_SIZE = 10 * 1024 * 1024;
|
|
243
244
|
function parseHTML(html) {
|
|
244
245
|
const warnings = [];
|
|
246
|
+
const inputSize = Buffer.byteLength(html, "utf-8");
|
|
247
|
+
if (inputSize > MAX_INPUT_SIZE) {
|
|
248
|
+
throw new ParsingError(
|
|
249
|
+
`Input exceeds maximum size of ${MAX_INPUT_SIZE / (1024 * 1024)} MB (received ${(inputSize / (1024 * 1024)).toFixed(1)} MB). Use CSS selectors to analyze specific sections of large documents.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
245
252
|
try {
|
|
246
253
|
const dom = new JSDOM(html, {
|
|
247
254
|
contentType: "text/html",
|
|
@@ -2012,6 +2019,236 @@ function formatStatesVoiceOver(node) {
|
|
|
2012
2019
|
return states.join(", ");
|
|
2013
2020
|
}
|
|
2014
2021
|
|
|
2022
|
+
// src/renderer/narrator-renderer.ts
|
|
2023
|
+
function renderNarrator(model, colorize) {
|
|
2024
|
+
const c = createColors(colorize ?? false);
|
|
2025
|
+
const announcements = [];
|
|
2026
|
+
renderNodeNarrator(model.root, announcements, c);
|
|
2027
|
+
return announcements.join("\n");
|
|
2028
|
+
}
|
|
2029
|
+
function renderNodeNarrator(node, announcements, c) {
|
|
2030
|
+
const announcement = formatNodeNarrator(node, c);
|
|
2031
|
+
if (announcement) {
|
|
2032
|
+
announcements.push(announcement);
|
|
2033
|
+
}
|
|
2034
|
+
for (const child of node.children) {
|
|
2035
|
+
if (child.name && child.name === node.name && node.children.length === 1) {
|
|
2036
|
+
for (const grandchild of child.children) {
|
|
2037
|
+
renderNodeNarrator(grandchild, announcements, c);
|
|
2038
|
+
}
|
|
2039
|
+
continue;
|
|
2040
|
+
}
|
|
2041
|
+
renderNodeNarrator(child, announcements, c);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
function formatNodeNarrator(node, c) {
|
|
2045
|
+
const parts = [];
|
|
2046
|
+
const roleFirst = shouldAnnounceRoleFirst2(node.role);
|
|
2047
|
+
if (roleFirst) {
|
|
2048
|
+
const roleText = formatRoleNarrator(node);
|
|
2049
|
+
if (roleText) {
|
|
2050
|
+
parts.push(c.roleName(roleText));
|
|
2051
|
+
}
|
|
2052
|
+
if (node.name) {
|
|
2053
|
+
parts.push(c.elementName(node.name));
|
|
2054
|
+
}
|
|
2055
|
+
} else {
|
|
2056
|
+
if (node.name) {
|
|
2057
|
+
parts.push(c.elementName(node.name));
|
|
2058
|
+
}
|
|
2059
|
+
const roleText = formatRoleNarrator(node);
|
|
2060
|
+
if (roleText) {
|
|
2061
|
+
parts.push(c.roleName(roleText));
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
const stateText = formatStatesNarrator(node);
|
|
2065
|
+
if (stateText) {
|
|
2066
|
+
parts.push(c.stateName(stateText));
|
|
2067
|
+
}
|
|
2068
|
+
if (node.value) {
|
|
2069
|
+
const valueText = node.value.text || String(node.value.current);
|
|
2070
|
+
if (valueText) {
|
|
2071
|
+
parts.push(valueText);
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
if (node.description) {
|
|
2075
|
+
parts.push(c.description(node.description));
|
|
2076
|
+
}
|
|
2077
|
+
return parts.join(", ");
|
|
2078
|
+
}
|
|
2079
|
+
function shouldAnnounceRoleFirst2(role) {
|
|
2080
|
+
const roleFirstRoles = [
|
|
2081
|
+
"link",
|
|
2082
|
+
"heading",
|
|
2083
|
+
"navigation",
|
|
2084
|
+
"main",
|
|
2085
|
+
"banner",
|
|
2086
|
+
"contentinfo",
|
|
2087
|
+
"complementary",
|
|
2088
|
+
"region",
|
|
2089
|
+
"form",
|
|
2090
|
+
"search",
|
|
2091
|
+
"blockquote",
|
|
2092
|
+
"figure",
|
|
2093
|
+
"dialog",
|
|
2094
|
+
"group",
|
|
2095
|
+
"document"
|
|
2096
|
+
];
|
|
2097
|
+
return roleFirstRoles.includes(role);
|
|
2098
|
+
}
|
|
2099
|
+
function formatRoleNarrator(node) {
|
|
2100
|
+
const role = node.role;
|
|
2101
|
+
switch (role) {
|
|
2102
|
+
case "button":
|
|
2103
|
+
return "button";
|
|
2104
|
+
case "link":
|
|
2105
|
+
return "link";
|
|
2106
|
+
case "heading":
|
|
2107
|
+
if (node.state.level) {
|
|
2108
|
+
return `Heading level ${node.state.level}`;
|
|
2109
|
+
}
|
|
2110
|
+
return "Heading";
|
|
2111
|
+
case "textbox":
|
|
2112
|
+
return "edit";
|
|
2113
|
+
case "checkbox":
|
|
2114
|
+
return "check box";
|
|
2115
|
+
case "radio":
|
|
2116
|
+
return "radio button";
|
|
2117
|
+
case "combobox":
|
|
2118
|
+
return "combo box";
|
|
2119
|
+
case "listbox":
|
|
2120
|
+
return "list box";
|
|
2121
|
+
case "option":
|
|
2122
|
+
return "option";
|
|
2123
|
+
case "list":
|
|
2124
|
+
return "list";
|
|
2125
|
+
case "listitem":
|
|
2126
|
+
return "list item";
|
|
2127
|
+
case "navigation":
|
|
2128
|
+
return "navigation";
|
|
2129
|
+
case "main":
|
|
2130
|
+
return "main";
|
|
2131
|
+
case "banner":
|
|
2132
|
+
return "banner";
|
|
2133
|
+
case "contentinfo":
|
|
2134
|
+
return "content info";
|
|
2135
|
+
case "complementary":
|
|
2136
|
+
return "complementary";
|
|
2137
|
+
case "form":
|
|
2138
|
+
return "form";
|
|
2139
|
+
case "search":
|
|
2140
|
+
return "search";
|
|
2141
|
+
case "region":
|
|
2142
|
+
return "region";
|
|
2143
|
+
case "img":
|
|
2144
|
+
return "image";
|
|
2145
|
+
case "article":
|
|
2146
|
+
return "article";
|
|
2147
|
+
case "generic":
|
|
2148
|
+
return "";
|
|
2149
|
+
case "staticText":
|
|
2150
|
+
case "paragraph":
|
|
2151
|
+
case "cell":
|
|
2152
|
+
case "term":
|
|
2153
|
+
case "definition":
|
|
2154
|
+
case "caption":
|
|
2155
|
+
return "";
|
|
2156
|
+
case "blockquote":
|
|
2157
|
+
return "block quote";
|
|
2158
|
+
case "code":
|
|
2159
|
+
return "code";
|
|
2160
|
+
case "table": {
|
|
2161
|
+
const rows = node.children.filter((c) => c.role === "row");
|
|
2162
|
+
const rowCount = rows.length;
|
|
2163
|
+
const colCount = rows.length > 0 ? rows[0].children.length : 0;
|
|
2164
|
+
return `table, ${rowCount} rows, ${colCount} columns`;
|
|
2165
|
+
}
|
|
2166
|
+
case "row":
|
|
2167
|
+
return "row";
|
|
2168
|
+
case "columnheader":
|
|
2169
|
+
return "column header";
|
|
2170
|
+
case "rowheader":
|
|
2171
|
+
return "row header";
|
|
2172
|
+
case "figure":
|
|
2173
|
+
return "figure";
|
|
2174
|
+
case "dialog":
|
|
2175
|
+
return "dialog";
|
|
2176
|
+
case "meter":
|
|
2177
|
+
return "meter";
|
|
2178
|
+
case "progressbar":
|
|
2179
|
+
return "progress bar";
|
|
2180
|
+
case "status":
|
|
2181
|
+
return "status";
|
|
2182
|
+
case "group":
|
|
2183
|
+
return node.name ? "group" : "";
|
|
2184
|
+
case "document":
|
|
2185
|
+
return "document";
|
|
2186
|
+
case "application":
|
|
2187
|
+
return "application";
|
|
2188
|
+
case "separator":
|
|
2189
|
+
return "separator";
|
|
2190
|
+
default:
|
|
2191
|
+
return role;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
function formatStatesNarrator(node) {
|
|
2195
|
+
const states = [];
|
|
2196
|
+
if (node.state.expanded !== void 0) {
|
|
2197
|
+
states.push(node.state.expanded ? "expanded" : "collapsed");
|
|
2198
|
+
}
|
|
2199
|
+
if (node.state.checked !== void 0) {
|
|
2200
|
+
if (node.state.checked === "mixed") {
|
|
2201
|
+
states.push("partially selected");
|
|
2202
|
+
} else {
|
|
2203
|
+
states.push(node.state.checked ? "checked" : "unchecked");
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
if (node.state.pressed !== void 0) {
|
|
2207
|
+
if (node.state.pressed === "mixed") {
|
|
2208
|
+
states.push("partially pressed");
|
|
2209
|
+
} else {
|
|
2210
|
+
states.push(node.state.pressed ? "pressed" : "not pressed");
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
if (node.state.selected !== void 0) {
|
|
2214
|
+
states.push(node.state.selected ? "selected" : "not selected");
|
|
2215
|
+
}
|
|
2216
|
+
if (node.state.disabled) {
|
|
2217
|
+
states.push("disabled");
|
|
2218
|
+
}
|
|
2219
|
+
if (node.state.invalid) {
|
|
2220
|
+
states.push("invalid");
|
|
2221
|
+
}
|
|
2222
|
+
if (node.state.required) {
|
|
2223
|
+
states.push("required");
|
|
2224
|
+
}
|
|
2225
|
+
if (node.state.readonly) {
|
|
2226
|
+
states.push("read only");
|
|
2227
|
+
}
|
|
2228
|
+
if (node.state.busy) {
|
|
2229
|
+
states.push("busy");
|
|
2230
|
+
}
|
|
2231
|
+
if (node.state.current) {
|
|
2232
|
+
if (node.state.current === "page") {
|
|
2233
|
+
states.push("current page");
|
|
2234
|
+
} else if (node.state.current === "step") {
|
|
2235
|
+
states.push("current step");
|
|
2236
|
+
} else if (node.state.current === "location") {
|
|
2237
|
+
states.push("current location");
|
|
2238
|
+
} else if (node.state.current === "date") {
|
|
2239
|
+
states.push("current date");
|
|
2240
|
+
} else if (node.state.current === "time") {
|
|
2241
|
+
states.push("current time");
|
|
2242
|
+
} else if (node.state.current === "true") {
|
|
2243
|
+
states.push("current");
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
if (node.state.grabbed !== void 0) {
|
|
2247
|
+
states.push(node.state.grabbed ? "grabbed" : "not grabbed");
|
|
2248
|
+
}
|
|
2249
|
+
return states.join(", ");
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2015
2252
|
// src/renderer/audit-renderer.ts
|
|
2016
2253
|
function renderAuditReport(model, colorize) {
|
|
2017
2254
|
const report = generateAuditReport(model);
|
|
@@ -2554,14 +2791,22 @@ function processHTML(html, options, diffHTML) {
|
|
|
2554
2791
|
}
|
|
2555
2792
|
return processNormal(html, options, warnings, colorize);
|
|
2556
2793
|
} catch (error) {
|
|
2557
|
-
if (error instanceof
|
|
2794
|
+
if (error instanceof ParsingError) {
|
|
2558
2795
|
return {
|
|
2559
|
-
output: `
|
|
2796
|
+
output: `Parse error: ${error.message}`,
|
|
2560
2797
|
exitCode: 2,
|
|
2561
2798
|
// Content error
|
|
2562
2799
|
warnings
|
|
2563
2800
|
};
|
|
2564
2801
|
}
|
|
2802
|
+
if (error instanceof Error) {
|
|
2803
|
+
return {
|
|
2804
|
+
output: `Error: ${error.message}`,
|
|
2805
|
+
exitCode: 3,
|
|
2806
|
+
// System error
|
|
2807
|
+
warnings
|
|
2808
|
+
};
|
|
2809
|
+
}
|
|
2565
2810
|
throw error;
|
|
2566
2811
|
}
|
|
2567
2812
|
}
|
|
@@ -2671,6 +2916,7 @@ function formatScreenReaderOutput(model, screenReader, colorize) {
|
|
|
2671
2916
|
const nvda = renderNVDA(model, colorize);
|
|
2672
2917
|
const jaws = renderJAWS(model, colorize);
|
|
2673
2918
|
const voiceover = renderVoiceOver(model, colorize);
|
|
2919
|
+
const narrator = renderNarrator(model, colorize);
|
|
2674
2920
|
const c = createColors(colorize);
|
|
2675
2921
|
return [
|
|
2676
2922
|
c.sectionHeader("=== NVDA ==="),
|
|
@@ -2680,7 +2926,10 @@ function formatScreenReaderOutput(model, screenReader, colorize) {
|
|
|
2680
2926
|
jaws,
|
|
2681
2927
|
"",
|
|
2682
2928
|
c.sectionHeader("=== VoiceOver ==="),
|
|
2683
|
-
voiceover
|
|
2929
|
+
voiceover,
|
|
2930
|
+
"",
|
|
2931
|
+
c.sectionHeader("=== Narrator ==="),
|
|
2932
|
+
narrator
|
|
2684
2933
|
].join("\n");
|
|
2685
2934
|
}
|
|
2686
2935
|
switch (screenReader) {
|
|
@@ -2690,6 +2939,8 @@ function formatScreenReaderOutput(model, screenReader, colorize) {
|
|
|
2690
2939
|
return renderJAWS(model, colorize);
|
|
2691
2940
|
case "voiceover":
|
|
2692
2941
|
return renderVoiceOver(model, colorize);
|
|
2942
|
+
case "narrator":
|
|
2943
|
+
return renderNarrator(model, colorize);
|
|
2693
2944
|
default:
|
|
2694
2945
|
throw new Error(`Unknown screen reader: ${screenReader}`);
|
|
2695
2946
|
}
|
|
@@ -2791,7 +3042,7 @@ program.argument("[input...]", 'HTML file path(s) or "-" for stdin').option("-o,
|
|
|
2791
3042
|
"json"
|
|
2792
3043
|
).option(
|
|
2793
3044
|
"-s, --screen-reader <reader>",
|
|
2794
|
-
"Screen reader: nvda, jaws, voiceover, or all (default: nvda)",
|
|
3045
|
+
"Screen reader: nvda, jaws, voiceover, narrator, or all (default: nvda)",
|
|
2795
3046
|
"nvda"
|
|
2796
3047
|
).option(
|
|
2797
3048
|
"--selector <selector>",
|
|
@@ -2862,6 +3113,7 @@ Screen Readers:
|
|
|
2862
3113
|
nvda - NVDA (Windows)
|
|
2863
3114
|
jaws - JAWS (Windows)
|
|
2864
3115
|
voiceover - VoiceOver (macOS)
|
|
3116
|
+
narrator - Narrator (Windows)
|
|
2865
3117
|
all - All screen readers
|
|
2866
3118
|
|
|
2867
3119
|
Output Formats:
|