tosijs-ui 1.16.41 → 1.17.0-beta.1
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/CHANGELOG.md +45 -0
- package/dist/doc-browser.d.ts +6 -0
- package/dist/doc-browser.js +10 -5
- package/dist/doc-system/book-html.d.ts +3 -1
- package/dist/doc-system/book-html.js +5 -2
- package/dist/doc-system/doc-system-styles.js +5 -1
- package/dist/doc-system/doc-system.js +4 -0
- package/dist/doc-system/render.d.ts +19 -0
- package/dist/doc-system/render.js +145 -1
- package/dist/doc-system/site/a11y-audit-cli.d.ts +1 -0
- package/dist/doc-system/site/a11y-audit-cli.js +103 -0
- package/dist/doc-system/site/a11y-audit.d.ts +30 -0
- package/dist/doc-system/site/a11y-audit.js +71 -0
- package/dist/doc-system/site/a11y-report.d.ts +22 -0
- package/dist/doc-system/site/a11y-report.js +138 -0
- package/dist/doc-system/site/alt-text.d.ts +31 -0
- package/dist/doc-system/site/alt-text.js +55 -0
- package/dist/doc-system/site/epub-a11y.d.ts +38 -0
- package/dist/doc-system/site/epub-a11y.js +119 -0
- package/dist/doc-system/site/epub.d.ts +11 -0
- package/dist/doc-system/site/epub.js +84 -19
- package/dist/doc-system/site/generate-site.d.ts +3 -1
- package/dist/doc-system/site/generate-site.js +33 -1
- package/dist/doc-system/site/index.d.ts +2 -0
- package/dist/doc-system/site/orchestrator.js +70 -5
- package/dist/doc-system/site/site-config.d.ts +21 -0
- package/dist/drag-and-drop.js +1 -1
- package/dist/iife.js +113 -111
- package/dist/iife.js.map +12 -12
- package/dist/localize.js +2 -2
- package/dist/palette.d.ts +2 -0
- package/dist/palette.js +38 -6
- package/dist/rich-text.js +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.17.0-beta.1
|
|
4
|
+
|
|
5
|
+
> Prerelease, published under the `beta` dist-tag: install with `tosijs-ui@beta`. `latest` stays
|
|
6
|
+
> on 1.16.41. It carries the accessibility half of 1.17.0; the SEO work and #245/#246 follow.
|
|
7
|
+
|
|
8
|
+
- **An opt-in accessibility mode for doc sites (#3521).** `accessibility: 'wcag22-aa'` in the
|
|
9
|
+
site config makes the build do what it can decide on its own and report what it cannot, on the
|
|
10
|
+
site, in print and in the ePub:
|
|
11
|
+
- an image alone in its paragraph, followed by an italic-only paragraph, renders as a
|
|
12
|
+
`<figure>` with that text as its `<figcaption>`;
|
|
13
|
+
- every page has exactly one `<h1>`, its title: a page's first heading is promoted when its
|
|
14
|
+
title came from it, a later `#` becomes `##`, and the rest shift to start at `##`;
|
|
15
|
+
- the build lists, by file and line, each image with no alt text and no caption, each heading
|
|
16
|
+
that skips a level, and each link named "here" or the like, and fails on them under `strict`.
|
|
17
|
+
|
|
18
|
+
Off by default; a site that does not set it renders exactly as before.
|
|
19
|
+
- **The mode audits the built site with axe.** A full build opens every page in Chromium and runs
|
|
20
|
+
axe-core's WCAG 2.2 AA rules, listing each failure by page, rule and element and failing under
|
|
21
|
+
`strict`. It needs `playwright` and `axe-core` in your project; without them the build says the
|
|
22
|
+
audit did not run rather than reporting a pass. Never under a dev server.
|
|
23
|
+
- **Accessible ePubs (#3522).** In the mode, the book's package document declares its access
|
|
24
|
+
modes, features, hazards and a summary (EPUB Accessibility 1.1), derived from the build: it
|
|
25
|
+
claims alternative text only when every image has alt text or a caption. Conformance to WCAG
|
|
26
|
+
2.2 AA is claimed only when DAISY Ace, installed in your project, has checked the finished book
|
|
27
|
+
and passed it. `epub: { accessibility: { summary, hazards, certifiedBy } }` overrides the
|
|
28
|
+
defaults. Checked against real Ace: this site's own book passes.
|
|
29
|
+
- **Every ePub, mode or not: the right language, readable links, landmarks.** Each chapter, the
|
|
30
|
+
nav, the contents and the cover said `xml:lang="en"` whatever the book's language, and the
|
|
31
|
+
package said none, so a reading system pronounced a French book as English. Links were
|
|
32
|
+
`#0088cc` (3.9:1 on white) and marked by colour alone; they are now 6.6:1 and underlined.
|
|
33
|
+
The nav gains a landmarks list (cover, contents, start of the text) and its table of contents
|
|
34
|
+
`role="doc-toc"`.
|
|
35
|
+
- **`altText`: a hook that supplies alt text for undescribed images.** With the mode on, it is
|
|
36
|
+
called for each image the build would report, and its answer becomes that image's alt text in
|
|
37
|
+
the built site, `docs.json` and the ePub. It never writes a source file. It runs on every
|
|
38
|
+
build, so cache anything slow by `src`.
|
|
39
|
+
- **The doc-site search trigger's name contains its visible text (#3569).** Its `aria-label`
|
|
40
|
+
was "Search (⌘K)" beside visible text "Search ⌘K", which fails WCAG 2.5.3 (a voice-control user
|
|
41
|
+
says what they see) on every page of every doc site. A `<tosi-palette>` field trigger is now
|
|
42
|
+
named by its text, an icon trigger keeps an `aria-label`, and the shortcut is announced through
|
|
43
|
+
`aria-keyshortcuts`. The frame's axe test now runs every WCAG 2.2 A and AA rule, not four.
|
|
44
|
+
- **The header's site name is no longer a heading.** It was an `<h2>` above every page's own
|
|
45
|
+
`<h1>`, so it was each page's first heading. It is now `<span class="site-name">` and looks the
|
|
46
|
+
same; site CSS that styled `header h2` should target `header .site-name`.
|
|
47
|
+
|
|
3
48
|
## 1.16.41
|
|
4
49
|
|
|
5
50
|
- **A `noindex` page is left out of `llms.txt`.** The sitemap and the markdown copies already
|
package/dist/doc-browser.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AccessibilityMode } from './doc-system/render.js';
|
|
1
2
|
import { TestResults } from './live-example/test-harness.js';
|
|
2
3
|
import { type ExamplePolicy } from './doc-system/example-policy.js';
|
|
3
4
|
export declare const hasTestBlock: (text: string | undefined, policy?: ExamplePolicy) => boolean;
|
|
@@ -123,6 +124,11 @@ export interface DocBrowserOptions {
|
|
|
123
124
|
* strings, like every other piece of the doc system's own text.
|
|
124
125
|
*/
|
|
125
126
|
searchPlaceholder?: string;
|
|
127
|
+
/**
|
|
128
|
+
* `SiteConfig.accessibility` (#3521): render figures with captions and exactly one `<h1>`
|
|
129
|
+
* per page, as the static build does, so a hydrated page matches it.
|
|
130
|
+
*/
|
|
131
|
+
accessibility?: AccessibilityMode;
|
|
126
132
|
/**
|
|
127
133
|
* Header-bar links. When provided, these replace the legacy `projectLinks` icon
|
|
128
134
|
* set in the header (each renders as an icon if `icon` names a known icon, else
|
package/dist/doc-browser.js
CHANGED
|
@@ -103,7 +103,7 @@ import { elements, vars, varDefault, bindings, getListItem, tosi, StyleSheet, }
|
|
|
103
103
|
import { buildSlugMap, pathForSlug, filenameForPath, homeDoc, wikilinkTargets, } from './doc-system/routing.js';
|
|
104
104
|
import { buildNavTree } from './doc-system/nav-tree.js';
|
|
105
105
|
import { navSectionStyleSpec } from './doc-system/nav-styles.js';
|
|
106
|
-
import { renderDocMarkdown } from './doc-system/render.js';
|
|
106
|
+
import { renderDocMarkdown, } from './doc-system/render.js';
|
|
107
107
|
import { pageTitle } from './doc-system/doc-title.js';
|
|
108
108
|
import { unsettledExamples, tallyExamples, closePage, } from './doc-system/test-completion.js';
|
|
109
109
|
import { LiveExample, testManager, pageTestCount, enableTests, disableTests, } from './live-example.js';
|
|
@@ -149,7 +149,7 @@ export const hasTestBlock = (text, policy = 'auto') => typeof text === 'string'
|
|
|
149
149
|
// Cheap reject first: lexing every doc in a large corpus is not free.
|
|
150
150
|
text.includes('test') &&
|
|
151
151
|
collectCodeTokens(text).some((t) => t.lang === 'test' && isLiveFence('test', t.mode, policy));
|
|
152
|
-
const { div, span, a, header, button, template,
|
|
152
|
+
const { div, span, a, header, button, template, img, ul, li } = elements;
|
|
153
153
|
/*
|
|
154
154
|
Test colors, and the text color that goes ON each — because contrast is a PAIR.
|
|
155
155
|
|
|
@@ -395,7 +395,7 @@ let docBrowserSeq = 0;
|
|
|
395
395
|
// Likewise for the nav's aria-controls ids.
|
|
396
396
|
let navIdCounter = 0;
|
|
397
397
|
export function createDocBrowser(options) {
|
|
398
|
-
const { docs, context = {}, projectName = '', projectLinks = {}, logo, navSize = 200, minSize = 600, routing = 'query', initialRoute, aliases = {}, onRouteChange, searchShortcut = 'mod+k', searchPlaceholder = 'Search', navbarLinks, contentElement, } = options;
|
|
398
|
+
const { docs, context = {}, projectName = '', projectLinks = {}, logo, navSize = 200, minSize = 600, routing = 'query', initialRoute, aliases = {}, onRouteChange, searchShortcut = 'mod+k', searchPlaceholder = 'Search', accessibility, navbarLinks, contentElement, } = options;
|
|
399
399
|
// Memory routing is fully self-contained: it never reads or writes
|
|
400
400
|
// window.history/location, so an embedded or nested browser can't hijack the
|
|
401
401
|
// host page's URL (or recurse into it).
|
|
@@ -906,7 +906,10 @@ export function createDocBrowser(options) {
|
|
|
906
906
|
alignItems: 'center',
|
|
907
907
|
borderBottom: 'none',
|
|
908
908
|
},
|
|
909
|
-
}, logoMark(logo, projectLinks),
|
|
909
|
+
}, logoMark(logo, projectLinks),
|
|
910
|
+
// Not a heading: the site name above every page made it each page's first heading,
|
|
911
|
+
// an <h2> ahead of the page's own <h1> (#3521).
|
|
912
|
+
span({ class: 'site-name' }, projectName)));
|
|
910
913
|
}
|
|
911
914
|
headerContent.push(span({ class: 'elastic' }), searchBox);
|
|
912
915
|
// A header link renders as an icon (if `icon` names a known icon) or its label.
|
|
@@ -1054,6 +1057,8 @@ export function createDocBrowser(options) {
|
|
|
1054
1057
|
docContent.innerHTML = renderDocMarkdown(doc.text, {
|
|
1055
1058
|
bakes: doc.bakes ? new Map(doc.bakes) : undefined,
|
|
1056
1059
|
wikilinks,
|
|
1060
|
+
accessible: !!accessibility,
|
|
1061
|
+
title: doc.title,
|
|
1057
1062
|
});
|
|
1058
1063
|
}
|
|
1059
1064
|
/*
|
|
@@ -1345,7 +1350,7 @@ export function createDocBrowser(options) {
|
|
|
1345
1350
|
return;
|
|
1346
1351
|
editUI.view = view;
|
|
1347
1352
|
if (view === 'preview') {
|
|
1348
|
-
docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path), { wikilinks });
|
|
1353
|
+
docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path), { wikilinks, accessible: !!accessibility, title: editUI.doc.title });
|
|
1349
1354
|
rewriteContentLinks();
|
|
1350
1355
|
LiveExample.insertExamples(docContent, context, editUI.doc.path || undefined);
|
|
1351
1356
|
focusableCodeBlocks(docContent);
|
|
@@ -8,7 +8,7 @@ export interface BookDoc {
|
|
|
8
8
|
parent?: string;
|
|
9
9
|
hidden?: boolean;
|
|
10
10
|
}
|
|
11
|
-
export declare const DEFAULT_BOOK_CSS = "/* doc-system book stylesheet */\nhtml { font-size: 100%; }\nbody {\n font-family: Georgia, 'Times New Roman', serif;\n line-height: 1.5;\n margin: 0 1em;\n color: #1a1a1a;\n}\nh1, h2, h3, h4, h5, h6 {\n font-family: -apple-system, 'Helvetica Neue', Arial, sans-serif;\n line-height: 1.2;\n margin: 1.4em 0 0.5em;\n}\nh1 { font-size: 1.8em; }\nh2 { font-size: 1.4em; }\nh3 { font-size: 1.2em; }\np { margin: 0.6em 0; }\na { color: #
|
|
11
|
+
export declare const DEFAULT_BOOK_CSS = "/* doc-system book stylesheet */\nhtml { font-size: 100%; }\nbody {\n font-family: Georgia, 'Times New Roman', serif;\n line-height: 1.5;\n margin: 0 1em;\n color: #1a1a1a;\n}\nh1, h2, h3, h4, h5, h6 {\n font-family: -apple-system, 'Helvetica Neue', Arial, sans-serif;\n line-height: 1.2;\n margin: 1.4em 0 0.5em;\n}\nh1 { font-size: 1.8em; }\nh2 { font-size: 1.4em; }\nh3 { font-size: 1.2em; }\np { margin: 0.6em 0; }\n/* 6.6:1 on white, and underlined: colour alone does not mark a link in running text (#3522, DAISY Ace) */\na { color: #0a5bb5; text-decoration: underline; text-underline-offset: 0.15em; }\nul, ol { margin: 0.6em 0; padding-left: 1.4em; }\nblockquote {\n margin: 0.8em 0;\n padding: 0 0 0 1em;\n border-left: 3px solid #ccc;\n color: #555;\n}\nimg { max-width: 100%; height: auto; }\ntable { border-collapse: collapse; margin: 0.8em 0; font-size: 0.85em; }\nth, td { border: 1px solid #ccc; padding: 0.3em 0.6em; text-align: left; }\ncode, pre {\n font-family: 'SF Mono', Menlo, Consolas, monospace;\n font-size: 0.8em;\n}\n:not(pre) > code {\n background: #f3f3f3;\n padding: 0.1em 0.3em;\n border-radius: 3px;\n}\npre {\n background: #f6f8fa;\n border: 1px solid #e1e4e8;\n border-radius: 4px;\n padding: 0.7em 0.9em;\n margin: 0.8em 0;\n white-space: pre-wrap;\n word-break: break-word;\n overflow-wrap: anywhere;\n}\npre code { background: none; padding: 0; }\n/*\nPrism token colours for the book \u2014 a LIGHT palette, deliberately distinct from the site's.\n\nThe site has TWO palettes, one per mode. A book has one background and it is light: the\npre rule above is #f6f8fa, an ePub reader may impose white or sepia, and paper is paper.\nReusing the site palette here would be light-on-light \u2014 invisible body text dressed as syntax\nhighlighting, which is the exact failure a nested diff hit in tosijs-ui#143.\n\nContrast-checked against #f6f8fa; the comment green is the weakest at ~4.6:1, still above\nWCAG AA for body text. Colours are literal rather than custom properties because this\nstylesheet ships inside an ePub, where custom properties are not reliably supported.\n\nNO BACKTICKS in this comment: it lives inside a JS template literal, so one would end the\nstring and drop the rest of the stylesheet into code. (It did. Same shape as a block comment\nending a doc comment early \u2014 punctuation that means something to the container.)\n*/\n.token.comment, .token.prolog, .token.cdata { color: #5c6f5c; font-style: italic; }\n.token.punctuation { color: #666; }\n.token.string, .token.char, .token.attr-value, .token.regex { color: #a03030; }\n.token.number, .token.boolean, .token.constant { color: #0b7285; }\n.token.keyword, .token.important, .token.atrule { color: #0a5bb5; }\n.token.function, .token.class-name { color: #7a4b00; }\n.token.operator, .token.entity, .token.url { color: #444; }\n.token.tag, .token.selector, .token.builtin { color: #0a6b52; }\n.token.attr-name, .token.property { color: #2a5db0; }\n.token.deleted { color: #b02020; }\n.token.inserted { color: #2a6b2a; }\n.token.bold { font-weight: bold; }\n.token.italic { font-style: italic; }\n/* the in-flow Contents page (ePub spine TOC) */\n.toc-title { text-align: center; }\nol.toc, ol.toc ol { list-style: none; padding-left: 0; }\nol.toc ol { padding-left: 1.2em; }\nol.toc li { margin: 0.3em 0; }\nol.toc > li > a { font-weight: bold; }\n/* per-example \"run this live\" deep link */\n.example-live-link { margin: 0.6em 0 0; font-size: 0.85em; }\n.example-live-link a { color: #0a5bb5; font-weight: bold; }\n/* footnotes / endnotes + wikilinks */\nsup.footnote-ref { font-size: 0.7em; line-height: 0; }\nsup.footnote-ref a { text-decoration: none; }\n.footnotes { margin-top: 2em; font-size: 0.9em; }\n.footnotes hr { border: none; border-top: 1px solid #ccc; margin: 1em 0; }\n.footnote-backref { text-decoration: none; }\na.wikilink { text-decoration: none; border-bottom: 1px dotted currentColor; }\n";
|
|
12
12
|
export declare const PRINT_CSS = "\n@page { margin: 18mm 16mm; }\n.book-cover { display: block; margin: 0 auto; max-width: 100%; max-height: 100vh; page-break-after: always; break-inside: avoid; }\n.book-title { text-align: center; margin: 30vh 0 1em; }\n.book-toc { page-break-after: always; }\n.book-toc h2 { page-break-before: avoid; }\n.book-toc ol { list-style: none; padding-left: 0; }\n.book-toc ol ol { padding-left: 1.2em; }\n.book-toc a { color: inherit; }\n.chapter { page-break-before: always; }\n";
|
|
13
13
|
export declare function escapeHtml(s: string): string;
|
|
14
14
|
export declare function slugify(s: string): string;
|
|
@@ -27,6 +27,8 @@ export interface BookHtmlOptions {
|
|
|
27
27
|
/** full stylesheet (defaults to DEFAULT_BOOK_CSS + PRINT_CSS) */
|
|
28
28
|
css?: string;
|
|
29
29
|
lang?: string;
|
|
30
|
+
/** `SiteConfig.accessibility`: figures and one `<h1>` per chapter (#3521) */
|
|
31
|
+
accessible?: boolean;
|
|
30
32
|
/** inject a script that opens the print dialog once the page has loaded */
|
|
31
33
|
autoPrint?: boolean;
|
|
32
34
|
}
|
|
@@ -28,7 +28,8 @@ h1 { font-size: 1.8em; }
|
|
|
28
28
|
h2 { font-size: 1.4em; }
|
|
29
29
|
h3 { font-size: 1.2em; }
|
|
30
30
|
p { margin: 0.6em 0; }
|
|
31
|
-
|
|
31
|
+
/* 6.6:1 on white, and underlined: colour alone does not mark a link in running text (#3522, DAISY Ace) */
|
|
32
|
+
a { color: #0a5bb5; text-decoration: underline; text-underline-offset: 0.15em; }
|
|
32
33
|
ul, ol { margin: 0.6em 0; padding-left: 1.4em; }
|
|
33
34
|
blockquote {
|
|
34
35
|
margin: 0.8em 0;
|
|
@@ -96,7 +97,7 @@ ol.toc li { margin: 0.3em 0; }
|
|
|
96
97
|
ol.toc > li > a { font-weight: bold; }
|
|
97
98
|
/* per-example "run this live" deep link */
|
|
98
99
|
.example-live-link { margin: 0.6em 0 0; font-size: 0.85em; }
|
|
99
|
-
.example-live-link a { color: #
|
|
100
|
+
.example-live-link a { color: #0a5bb5; font-weight: bold; }
|
|
100
101
|
/* footnotes / endnotes + wikilinks */
|
|
101
102
|
sup.footnote-ref { font-size: 0.7em; line-height: 0; }
|
|
102
103
|
sup.footnote-ref a { text-decoration: none; }
|
|
@@ -155,6 +156,8 @@ export function buildBookHtml(docs, opts) {
|
|
|
155
156
|
const chapters = flatten(roots)
|
|
156
157
|
.map((node) => `<section class="chapter" id="${slugify(node.doc.filename)}">\n${renderDocMarkdown(stripDocMeta(bookTextOf(node.doc)), {
|
|
157
158
|
wikilinks,
|
|
159
|
+
accessible: opts.accessible,
|
|
160
|
+
title: node.doc.title,
|
|
158
161
|
})}\n</section>`)
|
|
159
162
|
.join('\n');
|
|
160
163
|
return `<!DOCTYPE html>
|
|
@@ -393,9 +393,13 @@ export function docSystemStyleSpec(theme = {}) {
|
|
|
393
393
|
margin: `${vars.spacing} 0 ${vars.spacing200}`,
|
|
394
394
|
padding: 0,
|
|
395
395
|
},
|
|
396
|
-
'header
|
|
396
|
+
'header .site-name': {
|
|
397
397
|
color: vars.brandTextColor,
|
|
398
398
|
whiteSpace: 'nowrap',
|
|
399
|
+
fontWeight: '700',
|
|
400
|
+
fontSize: 'calc(var(--font-size) * var(--h2-scale))',
|
|
401
|
+
lineHeight: 'calc(var(--line-height) * var(--h2-scale))',
|
|
402
|
+
margin: 'calc(var(--spacing) * var(--h2-scale)) 0',
|
|
399
403
|
},
|
|
400
404
|
h2: {
|
|
401
405
|
color: vars.brandColor,
|
|
@@ -332,11 +332,13 @@ export class TosiDocSystem extends withAttributes({
|
|
|
332
332
|
let projectName = '';
|
|
333
333
|
let bookVolumes;
|
|
334
334
|
let ebooks;
|
|
335
|
+
let accessible = false;
|
|
335
336
|
try {
|
|
336
337
|
const parsed = JSON.parse(this.config || '{}');
|
|
337
338
|
projectName = parsed.projectName || '';
|
|
338
339
|
bookVolumes = parsed.bookVolumes || undefined;
|
|
339
340
|
ebooks = parsed.ebooks;
|
|
341
|
+
accessible = !!parsed.accessibility;
|
|
340
342
|
}
|
|
341
343
|
catch {
|
|
342
344
|
// ignore — fall through to document.title
|
|
@@ -372,6 +374,7 @@ export class TosiDocSystem extends withAttributes({
|
|
|
372
374
|
title: bookTitle,
|
|
373
375
|
cover: bookCover,
|
|
374
376
|
autoPrint: false,
|
|
377
|
+
accessible,
|
|
375
378
|
}));
|
|
376
379
|
win.document.close();
|
|
377
380
|
/*
|
|
@@ -609,6 +612,7 @@ export class TosiDocSystem extends withAttributes({
|
|
|
609
612
|
logo: config.logo,
|
|
610
613
|
navbarLinks: navbarLinks.length ? navbarLinks : undefined,
|
|
611
614
|
searchPlaceholder: config.searchPlaceholder,
|
|
615
|
+
accessibility: config.accessibility,
|
|
612
616
|
contentElement,
|
|
613
617
|
});
|
|
614
618
|
// Add the settings (theme + language) menu — except in a nested demo, where
|
|
@@ -3,12 +3,31 @@ export interface ExampleBake {
|
|
|
3
3
|
js: string;
|
|
4
4
|
}
|
|
5
5
|
export type ExampleBakes = Map<string, ExampleBake>;
|
|
6
|
+
/** `SiteConfig.accessibility`. One level for now; the name says which standard it targets. */
|
|
7
|
+
export type AccessibilityMode = 'wcag22-aa';
|
|
8
|
+
/**
|
|
9
|
+
* The accessibility mode's token rewrites (#3521), exported so the build's report reads exactly
|
|
10
|
+
* the structure the page renders.
|
|
11
|
+
*/
|
|
12
|
+
export declare function accessibleTokens(tokens: any[], title?: string): any[];
|
|
6
13
|
export declare function unwrapLoneCustomElements(html: string): string;
|
|
7
14
|
export declare function renderDocMarkdown(text: string, opts?: {
|
|
8
15
|
bakes?: ExampleBakes;
|
|
9
16
|
/** the corpus's `wikilinkTargets`; without it a `[[target]]` is slugified as written */
|
|
10
17
|
wikilinks?: Map<string, string>;
|
|
18
|
+
/**
|
|
19
|
+
* The accessibility mode (#3521): figures with captions, and exactly one `<h1>`. `title` is
|
|
20
|
+
* the page title, rendered as that `<h1>` when the markdown has none.
|
|
21
|
+
*/
|
|
22
|
+
accessible?: boolean;
|
|
23
|
+
title?: string;
|
|
11
24
|
}): string;
|
|
25
|
+
/**
|
|
26
|
+
* A doc's tokens as the accessibility mode renders them, for the build's report: the same
|
|
27
|
+
* lexer and the same rewrites as `renderDocMarkdown`, so the report and the page cannot
|
|
28
|
+
* disagree about what is a figure or which heading is the `<h1>`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function accessibleDocTokens(text: string, title?: string): any[];
|
|
12
31
|
/**
|
|
13
32
|
* Derive a clean <meta name="description"> from a doc's first prose paragraph.
|
|
14
33
|
* Skips the title heading, code fences, tables, quotes, html and image/link-only
|
|
@@ -10,13 +10,131 @@ give that example a stable anchor — see the docMarked renderer below.
|
|
|
10
10
|
*/
|
|
11
11
|
import { Marked, Renderer } from 'marked';
|
|
12
12
|
import { parseFenceInfo } from './example-policy.js';
|
|
13
|
-
import { HeadingIds, headingSlug } from './heading-id.js';
|
|
13
|
+
import { HeadingIds, headingSlug, headingText } from './heading-id.js';
|
|
14
14
|
import { pathForSlug, slugify } from './routing.js';
|
|
15
15
|
const baseRenderer = new Renderer();
|
|
16
16
|
let currentBakes;
|
|
17
17
|
let currentWikilinks;
|
|
18
18
|
// Per-parse, reset in `preprocess` below (same pattern as the footnote state).
|
|
19
19
|
let headingIds = new HeadingIds();
|
|
20
|
+
let currentA11y;
|
|
21
|
+
const isBlank = (t) => (t.type === 'text' && !String(t.raw).trim()) || t.type === 'br';
|
|
22
|
+
/** The image token of a paragraph whose only content is one image, else undefined. */
|
|
23
|
+
function loneImage(tokens) {
|
|
24
|
+
const content = tokens.filter((t) => !isBlank(t));
|
|
25
|
+
return content.length === 1 && content[0].type === 'image'
|
|
26
|
+
? content[0]
|
|
27
|
+
: undefined;
|
|
28
|
+
}
|
|
29
|
+
/** The em token of a paragraph whose only content is italic text, else undefined. */
|
|
30
|
+
function loneEmphasis(tokens) {
|
|
31
|
+
const content = tokens.filter((t) => !isBlank(t));
|
|
32
|
+
return content.length === 1 && content[0].type === 'em'
|
|
33
|
+
? content[0]
|
|
34
|
+
: undefined;
|
|
35
|
+
}
|
|
36
|
+
function figureFrom(paragraph) {
|
|
37
|
+
// `` and `*caption*` on consecutive lines are one paragraph to marked.
|
|
38
|
+
const content = paragraph.tokens.filter((t) => !isBlank(t));
|
|
39
|
+
if (content.length === 2 &&
|
|
40
|
+
content[0].type === 'image' &&
|
|
41
|
+
content[1].type === 'em')
|
|
42
|
+
return {
|
|
43
|
+
type: 'figure',
|
|
44
|
+
raw: paragraph.raw,
|
|
45
|
+
image: content[0],
|
|
46
|
+
caption: content[1],
|
|
47
|
+
};
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
function withFigures(tokens) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
53
|
+
const token = tokens[i];
|
|
54
|
+
if (token.type === 'blockquote' && Array.isArray(token.tokens)) {
|
|
55
|
+
token.tokens = withFigures(token.tokens);
|
|
56
|
+
}
|
|
57
|
+
if (token.type !== 'paragraph') {
|
|
58
|
+
out.push(token);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const joined = figureFrom(token);
|
|
62
|
+
if (joined) {
|
|
63
|
+
out.push(joined);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const image = loneImage(token.tokens);
|
|
67
|
+
let next = i + 1;
|
|
68
|
+
while (tokens[next]?.type === 'space')
|
|
69
|
+
next += 1;
|
|
70
|
+
const caption = image &&
|
|
71
|
+
tokens[next]?.type === 'paragraph' &&
|
|
72
|
+
loneEmphasis(tokens[next].tokens);
|
|
73
|
+
if (caption) {
|
|
74
|
+
out.push({
|
|
75
|
+
type: 'figure',
|
|
76
|
+
raw: tokens
|
|
77
|
+
.slice(i, next + 1)
|
|
78
|
+
.map((t) => t.raw)
|
|
79
|
+
.join(''),
|
|
80
|
+
image,
|
|
81
|
+
caption,
|
|
82
|
+
});
|
|
83
|
+
i = next;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
out.push(token);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
function withOneTitle(tokens, title) {
|
|
91
|
+
const headings = tokens.filter((t) => t.type === 'heading');
|
|
92
|
+
/*
|
|
93
|
+
A page with no `#` takes its title from its first heading, whatever its level, so that
|
|
94
|
+
heading IS the title: promote it rather than print the same words twice.
|
|
95
|
+
*/
|
|
96
|
+
const lead = headings[0];
|
|
97
|
+
const first = headings.find((t) => t.depth === 1) ??
|
|
98
|
+
(lead && title && headingText(lead).trim() === title.trim()
|
|
99
|
+
? lead
|
|
100
|
+
: undefined);
|
|
101
|
+
// The shift comes from the sub-headings as written; a demoted `#` is not one of them.
|
|
102
|
+
const below = headings
|
|
103
|
+
.filter((h) => h !== first && h.depth > 1)
|
|
104
|
+
.map((h) => h.depth);
|
|
105
|
+
const shift = below.length ? Math.min(...below) - 2 : 0;
|
|
106
|
+
if (first)
|
|
107
|
+
first.depth = 1;
|
|
108
|
+
for (const h of headings) {
|
|
109
|
+
if (h === first)
|
|
110
|
+
continue;
|
|
111
|
+
if (h.depth === 1)
|
|
112
|
+
h.depth = 2;
|
|
113
|
+
else if (shift > 0)
|
|
114
|
+
h.depth -= shift;
|
|
115
|
+
}
|
|
116
|
+
if (!first && title)
|
|
117
|
+
return [
|
|
118
|
+
{
|
|
119
|
+
type: 'heading',
|
|
120
|
+
raw: '',
|
|
121
|
+
depth: 1,
|
|
122
|
+
text: title,
|
|
123
|
+
tokens: [
|
|
124
|
+
{ type: 'text', raw: title, text: escapeHtml(title), escaped: true },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
...tokens,
|
|
128
|
+
];
|
|
129
|
+
return tokens;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The accessibility mode's token rewrites (#3521), exported so the build's report reads exactly
|
|
133
|
+
* the structure the page renders.
|
|
134
|
+
*/
|
|
135
|
+
export function accessibleTokens(tokens, title) {
|
|
136
|
+
return withOneTitle(withFigures(tokens), title);
|
|
137
|
+
}
|
|
20
138
|
const docMarked = new Marked();
|
|
21
139
|
docMarked.use({
|
|
22
140
|
renderer: {
|
|
@@ -86,6 +204,13 @@ let footnoteDefs = new Map();
|
|
|
86
204
|
let footnoteOrder = [];
|
|
87
205
|
docMarked.use({
|
|
88
206
|
hooks: {
|
|
207
|
+
processAllTokens(tokens) {
|
|
208
|
+
if (!currentA11y)
|
|
209
|
+
return tokens;
|
|
210
|
+
const rewritten = accessibleTokens([...tokens], currentA11y.title);
|
|
211
|
+
// A TokensList carries `links` (reference definitions); keep it.
|
|
212
|
+
return Object.assign(rewritten, { links: tokens.links });
|
|
213
|
+
},
|
|
89
214
|
preprocess(markdown) {
|
|
90
215
|
footnoteDefs = new Map();
|
|
91
216
|
footnoteOrder = [];
|
|
@@ -106,6 +231,15 @@ docMarked.use({
|
|
|
106
231
|
},
|
|
107
232
|
},
|
|
108
233
|
extensions: [
|
|
234
|
+
{
|
|
235
|
+
// Built only by `accessibleTokens` above; there is no syntax of its own to tokenize.
|
|
236
|
+
name: 'figure',
|
|
237
|
+
renderer(token) {
|
|
238
|
+
const image = this.parser.parseInline([token.image]);
|
|
239
|
+
const caption = this.parser.parseInline(token.caption.tokens);
|
|
240
|
+
return `<figure>${image}<figcaption>${caption}</figcaption></figure>\n`;
|
|
241
|
+
},
|
|
242
|
+
},
|
|
109
243
|
{
|
|
110
244
|
// [[slug]] / [[slug|label]] → link to /slug/. Not matched inside code spans
|
|
111
245
|
// (Marked tokenizes those first). Given the corpus's `wikilinkTargets`, a target is
|
|
@@ -209,14 +343,24 @@ export function unwrapLoneCustomElements(html) {
|
|
|
209
343
|
export function renderDocMarkdown(text, opts = {}) {
|
|
210
344
|
currentBakes = opts.bakes;
|
|
211
345
|
currentWikilinks = opts.wikilinks;
|
|
346
|
+
currentA11y = opts.accessible ? { title: opts.title } : undefined;
|
|
212
347
|
try {
|
|
213
348
|
return unwrapLoneCustomElements(docMarked.parse(text));
|
|
214
349
|
}
|
|
215
350
|
finally {
|
|
216
351
|
currentBakes = undefined;
|
|
217
352
|
currentWikilinks = undefined;
|
|
353
|
+
currentA11y = undefined;
|
|
218
354
|
}
|
|
219
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* A doc's tokens as the accessibility mode renders them, for the build's report: the same
|
|
358
|
+
* lexer and the same rewrites as `renderDocMarkdown`, so the report and the page cannot
|
|
359
|
+
* disagree about what is a figure or which heading is the `<h1>`.
|
|
360
|
+
*/
|
|
361
|
+
export function accessibleDocTokens(text, title) {
|
|
362
|
+
return accessibleTokens([...docMarked.lexer(text)], title);
|
|
363
|
+
}
|
|
220
364
|
/**
|
|
221
365
|
* Derive a clean <meta name="description"> from a doc's first prose paragraph.
|
|
222
366
|
* Skips the title heading, code fences, tables, quotes, html and image/link-only
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The accessibility mode's axe audit, run in a child process (#3521, item 6).
|
|
3
|
+
|
|
4
|
+
Serves the built site, opens every page in Chromium, waits for it to hydrate, and runs axe-core
|
|
5
|
+
with the WCAG 2.0–2.2 A and AA rules. A child for the same reason as the ePub: a browser and
|
|
6
|
+
everything it strands go back to the OS on exit, and a build process may live for days.
|
|
7
|
+
|
|
8
|
+
Playwright and axe-core are the ADOPTER's: resolved from the project, never dependencies of
|
|
9
|
+
tosijs-ui. When either is missing, or Chromium is not installed, it says which and exits
|
|
10
|
+
NOT_RUN_EXIT, so the build can say the audit did not run instead of reporting a pass.
|
|
11
|
+
|
|
12
|
+
bun a11y-audit-cli.ts <payload.json> # payload = { outputDir, basePath, pages }
|
|
13
|
+
stdout: one JSON line, { violations: AxeFinding[] }
|
|
14
|
+
|
|
15
|
+
Build-time only. Never import this from browser code.
|
|
16
|
+
*/
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
import { createRequire } from 'module';
|
|
19
|
+
import { existsSync } from 'fs';
|
|
20
|
+
import { NOT_RUN_EXIT, AXE_TAGS } from './a11y-audit.js';
|
|
21
|
+
const payloadPath = process.argv[2];
|
|
22
|
+
const { outputDir, basePath, pages } = JSON.parse(await Bun.file(payloadPath).text());
|
|
23
|
+
const notRun = (why) => {
|
|
24
|
+
console.error(why);
|
|
25
|
+
process.exit(NOT_RUN_EXIT);
|
|
26
|
+
};
|
|
27
|
+
// Resolve from the project, where an adopter installs them, not from this package.
|
|
28
|
+
const projectRequire = createRequire(path.resolve('package.json'));
|
|
29
|
+
let axePath = '';
|
|
30
|
+
let playwright;
|
|
31
|
+
try {
|
|
32
|
+
axePath = projectRequire.resolve('axe-core/axe.min.js');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
notRun('axe-core is not installed in this project (bun add -d axe-core)');
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
playwright = await import(projectRequire.resolve('playwright'));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
notRun('playwright is not installed in this project (bun add -d playwright)');
|
|
42
|
+
}
|
|
43
|
+
const root = path.resolve(outputDir);
|
|
44
|
+
const prefix = (basePath || '').replace(/\/$/, '');
|
|
45
|
+
const server = Bun.serve({
|
|
46
|
+
port: 0,
|
|
47
|
+
fetch(request) {
|
|
48
|
+
let pathname = decodeURIComponent(new URL(request.url).pathname);
|
|
49
|
+
if (prefix && pathname.startsWith(prefix))
|
|
50
|
+
pathname = pathname.slice(prefix.length) || '/';
|
|
51
|
+
if (pathname.endsWith('/'))
|
|
52
|
+
pathname += 'index.html';
|
|
53
|
+
const file = path.join(root, pathname);
|
|
54
|
+
if (!file.startsWith(root) || !existsSync(file))
|
|
55
|
+
return new Response('not found', { status: 404 });
|
|
56
|
+
return new Response(Bun.file(file));
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
let browser;
|
|
60
|
+
try {
|
|
61
|
+
browser = await playwright.chromium.launch();
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
server.stop(true);
|
|
65
|
+
notRun(`Chromium could not be launched (bunx playwright install chromium): ${error.message.split('\n')[0]}`);
|
|
66
|
+
}
|
|
67
|
+
const violations = [];
|
|
68
|
+
try {
|
|
69
|
+
const context = await browser.newContext();
|
|
70
|
+
const page = await context.newPage();
|
|
71
|
+
for (const pagePath of pages) {
|
|
72
|
+
await page.goto(`http://localhost:${server.port}${prefix}${pagePath}`, {
|
|
73
|
+
waitUntil: 'load',
|
|
74
|
+
});
|
|
75
|
+
// Audit the page a reader uses: hydrated when the bundle loads, static if it does not.
|
|
76
|
+
if (await page.$('tosi-doc-system'))
|
|
77
|
+
await page
|
|
78
|
+
.waitForFunction(() => customElements.get('tosi-doc-system'), null, {
|
|
79
|
+
timeout: 10_000,
|
|
80
|
+
})
|
|
81
|
+
.catch(() => undefined);
|
|
82
|
+
await page.waitForTimeout(300);
|
|
83
|
+
await page.addScriptTag({ path: axePath });
|
|
84
|
+
const found = await page.evaluate(async (tags) => {
|
|
85
|
+
const result = await window.axe.run(document, {
|
|
86
|
+
runOnly: { type: 'tag', values: tags },
|
|
87
|
+
});
|
|
88
|
+
return result.violations.map((v) => ({
|
|
89
|
+
rule: v.id,
|
|
90
|
+
impact: v.impact,
|
|
91
|
+
help: v.help,
|
|
92
|
+
targets: v.nodes.map((n) => n.target.join(' ')),
|
|
93
|
+
}));
|
|
94
|
+
}, AXE_TAGS);
|
|
95
|
+
for (const v of found)
|
|
96
|
+
violations.push({ page: pagePath, ...v });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await browser.close();
|
|
101
|
+
server.stop(true);
|
|
102
|
+
}
|
|
103
|
+
console.log(JSON.stringify({ violations }));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** The child's exit code when it could not run at all (a missing package or browser). */
|
|
2
|
+
export declare const NOT_RUN_EXIT = 3;
|
|
3
|
+
/** axe-core's tags for WCAG 2.0, 2.1 and 2.2 at levels A and AA. */
|
|
4
|
+
export declare const AXE_TAGS: string[];
|
|
5
|
+
export interface AxeFinding {
|
|
6
|
+
/** the page's path, as served */
|
|
7
|
+
page: string;
|
|
8
|
+
rule: string;
|
|
9
|
+
impact: string | null;
|
|
10
|
+
help: string;
|
|
11
|
+
/** a CSS selector for each element that fails the rule */
|
|
12
|
+
targets: string[];
|
|
13
|
+
}
|
|
14
|
+
export type AxeAuditResult = {
|
|
15
|
+
ran: true;
|
|
16
|
+
violations: AxeFinding[];
|
|
17
|
+
} | {
|
|
18
|
+
ran: false;
|
|
19
|
+
reason: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function auditBuiltSite(opts: {
|
|
22
|
+
outputDir: string;
|
|
23
|
+
basePath?: string;
|
|
24
|
+
/** page paths to audit, e.g. `/`, `/lighting/` */
|
|
25
|
+
pages: string[];
|
|
26
|
+
/** the project Playwright and axe-core are resolved from (default: the working directory) */
|
|
27
|
+
projectDir?: string;
|
|
28
|
+
}): Promise<AxeAuditResult>;
|
|
29
|
+
/** The report: one line per rule per page, worst impact first. */
|
|
30
|
+
export declare function formatAxeFindings(violations: AxeFinding[]): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The parent side of the accessibility mode's axe audit (#3521, item 6). The browser work is in
|
|
3
|
+
`a11y-audit-cli.ts`, in a child process.
|
|
4
|
+
|
|
5
|
+
When it runs: with `accessibility` on, on the first full build in a process, and never under a
|
|
6
|
+
dev server (a watch rebuild, or `bun start`'s first build). Auditing every page in a browser
|
|
7
|
+
takes seconds per page; that is a release step, not something to repeat on each keystroke.
|
|
8
|
+
|
|
9
|
+
What it never does is report a pass it did not earn: when Playwright, axe-core or Chromium is
|
|
10
|
+
missing, the result says the audit did not run and why, and the build prints that.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'bun';
|
|
13
|
+
import { existsSync } from 'fs';
|
|
14
|
+
import { tmpdir } from 'os';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
/** The child's exit code when it could not run at all (a missing package or browser). */
|
|
17
|
+
export const NOT_RUN_EXIT = 3;
|
|
18
|
+
/** axe-core's tags for WCAG 2.0, 2.1 and 2.2 at levels A and AA. */
|
|
19
|
+
export const AXE_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
|
|
20
|
+
export async function auditBuiltSite(opts) {
|
|
21
|
+
const cliTs = `${import.meta.dir}/a11y-audit-cli.ts`;
|
|
22
|
+
const cli = existsSync(cliTs) ? cliTs : `${import.meta.dir}/a11y-audit-cli.js`;
|
|
23
|
+
const payloadPath = path.join(tmpdir(), `tosijs-a11y-${process.pid}.json`);
|
|
24
|
+
await Bun.write(payloadPath, JSON.stringify({
|
|
25
|
+
outputDir: path.resolve(opts.outputDir),
|
|
26
|
+
basePath: opts.basePath ?? '',
|
|
27
|
+
pages: opts.pages,
|
|
28
|
+
}));
|
|
29
|
+
// `--no-install`: without it Bun resolves a package the project does not have from its global
|
|
30
|
+
// cache, so "not installed" would never be said and the audit would use a Playwright nobody chose.
|
|
31
|
+
const child = spawn(['bun', '--no-install', cli, payloadPath], {
|
|
32
|
+
cwd: opts.projectDir,
|
|
33
|
+
stdout: 'pipe',
|
|
34
|
+
stderr: 'pipe',
|
|
35
|
+
});
|
|
36
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
37
|
+
new Response(child.stdout).text(),
|
|
38
|
+
new Response(child.stderr).text(),
|
|
39
|
+
child.exited,
|
|
40
|
+
]);
|
|
41
|
+
if (code === NOT_RUN_EXIT)
|
|
42
|
+
return { ran: false, reason: stderr.trim() };
|
|
43
|
+
const line = stdout.trim().split('\n').pop() ?? '';
|
|
44
|
+
try {
|
|
45
|
+
if (code === 0)
|
|
46
|
+
return { ran: true, ...JSON.parse(line) };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// fall through: a run that printed no result did not run
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
ran: false,
|
|
53
|
+
reason: `the audit failed (exit ${code}): ${stderr.trim().slice(0, 400)}`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** The report: one line per rule per page, worst impact first. */
|
|
57
|
+
export function formatAxeFindings(violations) {
|
|
58
|
+
const rank = ['critical', 'serious', 'moderate', 'minor'];
|
|
59
|
+
return [...violations]
|
|
60
|
+
.sort((a, b) => rank.indexOf(a.impact ?? 'minor') - rank.indexOf(b.impact ?? 'minor') ||
|
|
61
|
+
a.page.localeCompare(b.page))
|
|
62
|
+
.map((v) => ` ${v.page} ${v.rule} (${v.impact ?? 'unknown'}): ${v.help}\n` +
|
|
63
|
+
v.targets
|
|
64
|
+
.slice(0, 3)
|
|
65
|
+
.map((t) => ` ${t}`)
|
|
66
|
+
.join('\n') +
|
|
67
|
+
(v.targets.length > 3
|
|
68
|
+
? `\n … and ${v.targets.length - 3} more`
|
|
69
|
+
: ''))
|
|
70
|
+
.join('\n');
|
|
71
|
+
}
|