tosijs-ui 1.16.41 → 1.17.0-beta.2
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 +74 -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 +161 -1
- package/dist/doc-system/site/a11y-audit-cli.d.ts +1 -0
- package/dist/doc-system/site/a11y-audit-cli.js +139 -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 +44 -0
- package/dist/doc-system/site/epub-a11y.js +127 -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 +83 -5
- package/dist/doc-system/site/sections.d.ts +14 -0
- package/dist/doc-system/site/sections.js +34 -12
- package/dist/doc-system/site/site-config.d.ts +23 -1
- 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,79 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.17.0-beta.2
|
|
4
|
+
|
|
5
|
+
> Prerelease, published under the `beta` dist-tag: install with `tosijs-ui@beta`. `latest` stays
|
|
6
|
+
> on 1.16.41. Fixes from Learn 3D's first run of beta.1.
|
|
7
|
+
|
|
8
|
+
- **Accessibility mode: headings in a blockquote or list shift with the rest of the page
|
|
9
|
+
(#3576).** A sidebar's `> ####` under `###` sections stayed an `<h4>` under the new `<h2>`s, so
|
|
10
|
+
the build reported a skipped level and DAISY Ace failed the ePub on heading order. A heading
|
|
11
|
+
inside a blockquote still never becomes the page's title.
|
|
12
|
+
- **DAISY Ace is found on `PATH` (#3577).** The docs said `bun add -d @daisy/ace`, which brings
|
|
13
|
+
Electron and Puppeteer into the project, and their advisories then failed the build's own
|
|
14
|
+
dependency audit. Install it globally (`npm install -g @daisy/ace`) instead; the build looks in
|
|
15
|
+
the project first, then on `PATH`.
|
|
16
|
+
- **The axe audit is about twice as fast, and audits the page a reader gets (#3585).** Pages are
|
|
17
|
+
audited four at a time (this site: 80s to 43s). Each is audited once it stops adding elements,
|
|
18
|
+
rather than after a fixed 300ms, which on a busy machine caught pages half-built. And the
|
|
19
|
+
audit no longer starts the doc browser's background test runner, which runs on localhost and
|
|
20
|
+
never on a deployed site. `TOSIJS_A11Y_AUDIT=off` skips the audit and the Ace check for one
|
|
21
|
+
build while the mode stays on; the ePub then claims no conformance.
|
|
22
|
+
- **Section stubs are not written where they cannot be read, or mid-edit (#3584).** A page whose
|
|
23
|
+
`parent` names a missing doc gets a stub section doc in `sectionsDir`. When `sectionsDir` is
|
|
24
|
+
outside every `docPaths` entry, the build now warns and writes nothing, since the stub would
|
|
25
|
+
never be read back. Stubs are created only on the first build in a process, so a parent that
|
|
26
|
+
disappears for a second during a rename no longer leaves files behind. Each stub written is
|
|
27
|
+
named in the build output.
|
|
28
|
+
- **`preview.host` is optional, and the preview docs no longer say `host` is required (#3586).**
|
|
29
|
+
The type has allowed a config with only `preview.tunnel` since 1.10.0, but the doc comment and
|
|
30
|
+
the reference table still said so. `as any` casts written for it can go.
|
|
31
|
+
|
|
32
|
+
### 1.17.0-beta.1 (prerelease detail)
|
|
33
|
+
|
|
34
|
+
> Prerelease, published under the `beta` dist-tag: install with `tosijs-ui@beta`. `latest` stays
|
|
35
|
+
> on 1.16.41. It carries the accessibility half of 1.17.0; the SEO work and #245/#246 follow.
|
|
36
|
+
|
|
37
|
+
- **An opt-in accessibility mode for doc sites (#3521).** `accessibility: 'wcag22-aa'` in the
|
|
38
|
+
site config makes the build do what it can decide on its own and report what it cannot, on the
|
|
39
|
+
site, in print and in the ePub:
|
|
40
|
+
- an image alone in its paragraph, followed by an italic-only paragraph, renders as a
|
|
41
|
+
`<figure>` with that text as its `<figcaption>`;
|
|
42
|
+
- every page has exactly one `<h1>`, its title: a page's first heading is promoted when its
|
|
43
|
+
title came from it, a later `#` becomes `##`, and the rest shift to start at `##`;
|
|
44
|
+
- the build lists, by file and line, each image with no alt text and no caption, each heading
|
|
45
|
+
that skips a level, and each link named "here" or the like, and fails on them under `strict`.
|
|
46
|
+
|
|
47
|
+
Off by default; a site that does not set it renders exactly as before.
|
|
48
|
+
- **The mode audits the built site with axe.** A full build opens every page in Chromium and runs
|
|
49
|
+
axe-core's WCAG 2.2 AA rules, listing each failure by page, rule and element and failing under
|
|
50
|
+
`strict`. It needs `playwright` and `axe-core` in your project; without them the build says the
|
|
51
|
+
audit did not run rather than reporting a pass. Never under a dev server.
|
|
52
|
+
- **Accessible ePubs (#3522).** In the mode, the book's package document declares its access
|
|
53
|
+
modes, features, hazards and a summary (EPUB Accessibility 1.1), derived from the build: it
|
|
54
|
+
claims alternative text only when every image has alt text or a caption. Conformance to WCAG
|
|
55
|
+
2.2 AA is claimed only when DAISY Ace, installed in your project, has checked the finished book
|
|
56
|
+
and passed it. `epub: { accessibility: { summary, hazards, certifiedBy } }` overrides the
|
|
57
|
+
defaults. Checked against real Ace: this site's own book passes.
|
|
58
|
+
- **Every ePub, mode or not: the right language, readable links, landmarks.** Each chapter, the
|
|
59
|
+
nav, the contents and the cover said `xml:lang="en"` whatever the book's language, and the
|
|
60
|
+
package said none, so a reading system pronounced a French book as English. Links were
|
|
61
|
+
`#0088cc` (3.9:1 on white) and marked by colour alone; they are now 6.6:1 and underlined.
|
|
62
|
+
The nav gains a landmarks list (cover, contents, start of the text) and its table of contents
|
|
63
|
+
`role="doc-toc"`.
|
|
64
|
+
- **`altText`: a hook that supplies alt text for undescribed images.** With the mode on, it is
|
|
65
|
+
called for each image the build would report, and its answer becomes that image's alt text in
|
|
66
|
+
the built site, `docs.json` and the ePub. It never writes a source file. It runs on every
|
|
67
|
+
build, so cache anything slow by `src`.
|
|
68
|
+
- **The doc-site search trigger's name contains its visible text (#3569).** Its `aria-label`
|
|
69
|
+
was "Search (⌘K)" beside visible text "Search ⌘K", which fails WCAG 2.5.3 (a voice-control user
|
|
70
|
+
says what they see) on every page of every doc site. A `<tosi-palette>` field trigger is now
|
|
71
|
+
named by its text, an icon trigger keeps an `aria-label`, and the shortcut is announced through
|
|
72
|
+
`aria-keyshortcuts`. The frame's axe test now runs every WCAG 2.2 A and AA rule, not four.
|
|
73
|
+
- **The header's site name is no longer a heading.** It was an `<h2>` above every page's own
|
|
74
|
+
`<h1>`, so it was each page's first heading. It is now `<span class="site-name">` and looks the
|
|
75
|
+
same; site CSS that styled `header h2` should target `header .site-name`.
|
|
76
|
+
|
|
3
77
|
## 1.16.41
|
|
4
78
|
|
|
5
79
|
- **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,147 @@ 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
|
+
// Every heading, including those in blockquotes and lists: a sidebar's `> ####` shifts with the
|
|
91
|
+
// page's own headings, or it lands a level too deep (#3576).
|
|
92
|
+
function allHeadings(tokens, out = []) {
|
|
93
|
+
for (const t of tokens) {
|
|
94
|
+
if (t.type === 'heading')
|
|
95
|
+
out.push(t);
|
|
96
|
+
else if (t.type === 'blockquote')
|
|
97
|
+
allHeadings(t.tokens ?? [], out);
|
|
98
|
+
else if (t.type === 'list')
|
|
99
|
+
for (const item of t.items ?? [])
|
|
100
|
+
allHeadings(item.tokens ?? [], out);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function withOneTitle(tokens, title) {
|
|
105
|
+
// Only a top-level heading can be the page's title.
|
|
106
|
+
const headings = tokens.filter((t) => t.type === 'heading');
|
|
107
|
+
const nested = allHeadings(tokens);
|
|
108
|
+
/*
|
|
109
|
+
A page with no `#` takes its title from its first heading, whatever its level, so that
|
|
110
|
+
heading IS the title: promote it rather than print the same words twice.
|
|
111
|
+
*/
|
|
112
|
+
const lead = headings[0];
|
|
113
|
+
const first = headings.find((t) => t.depth === 1) ??
|
|
114
|
+
(lead && title && headingText(lead).trim() === title.trim()
|
|
115
|
+
? lead
|
|
116
|
+
: undefined);
|
|
117
|
+
// The shift comes from the sub-headings as written; a demoted `#` is not one of them.
|
|
118
|
+
const below = nested
|
|
119
|
+
.filter((h) => h !== first && h.depth > 1)
|
|
120
|
+
.map((h) => h.depth);
|
|
121
|
+
const shift = below.length ? Math.min(...below) - 2 : 0;
|
|
122
|
+
if (first)
|
|
123
|
+
first.depth = 1;
|
|
124
|
+
for (const h of nested) {
|
|
125
|
+
if (h === first)
|
|
126
|
+
continue;
|
|
127
|
+
if (h.depth === 1)
|
|
128
|
+
h.depth = 2;
|
|
129
|
+
else if (shift > 0)
|
|
130
|
+
h.depth -= shift;
|
|
131
|
+
}
|
|
132
|
+
if (!first && title)
|
|
133
|
+
return [
|
|
134
|
+
{
|
|
135
|
+
type: 'heading',
|
|
136
|
+
raw: '',
|
|
137
|
+
depth: 1,
|
|
138
|
+
text: title,
|
|
139
|
+
tokens: [
|
|
140
|
+
{ type: 'text', raw: title, text: escapeHtml(title), escaped: true },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
...tokens,
|
|
144
|
+
];
|
|
145
|
+
return tokens;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The accessibility mode's token rewrites (#3521), exported so the build's report reads exactly
|
|
149
|
+
* the structure the page renders.
|
|
150
|
+
*/
|
|
151
|
+
export function accessibleTokens(tokens, title) {
|
|
152
|
+
return withOneTitle(withFigures(tokens), title);
|
|
153
|
+
}
|
|
20
154
|
const docMarked = new Marked();
|
|
21
155
|
docMarked.use({
|
|
22
156
|
renderer: {
|
|
@@ -86,6 +220,13 @@ let footnoteDefs = new Map();
|
|
|
86
220
|
let footnoteOrder = [];
|
|
87
221
|
docMarked.use({
|
|
88
222
|
hooks: {
|
|
223
|
+
processAllTokens(tokens) {
|
|
224
|
+
if (!currentA11y)
|
|
225
|
+
return tokens;
|
|
226
|
+
const rewritten = accessibleTokens([...tokens], currentA11y.title);
|
|
227
|
+
// A TokensList carries `links` (reference definitions); keep it.
|
|
228
|
+
return Object.assign(rewritten, { links: tokens.links });
|
|
229
|
+
},
|
|
89
230
|
preprocess(markdown) {
|
|
90
231
|
footnoteDefs = new Map();
|
|
91
232
|
footnoteOrder = [];
|
|
@@ -106,6 +247,15 @@ docMarked.use({
|
|
|
106
247
|
},
|
|
107
248
|
},
|
|
108
249
|
extensions: [
|
|
250
|
+
{
|
|
251
|
+
// Built only by `accessibleTokens` above; there is no syntax of its own to tokenize.
|
|
252
|
+
name: 'figure',
|
|
253
|
+
renderer(token) {
|
|
254
|
+
const image = this.parser.parseInline([token.image]);
|
|
255
|
+
const caption = this.parser.parseInline(token.caption.tokens);
|
|
256
|
+
return `<figure>${image}<figcaption>${caption}</figcaption></figure>\n`;
|
|
257
|
+
},
|
|
258
|
+
},
|
|
109
259
|
{
|
|
110
260
|
// [[slug]] / [[slug|label]] → link to /slug/. Not matched inside code spans
|
|
111
261
|
// (Marked tokenizes those first). Given the corpus's `wikilinkTargets`, a target is
|
|
@@ -209,14 +359,24 @@ export function unwrapLoneCustomElements(html) {
|
|
|
209
359
|
export function renderDocMarkdown(text, opts = {}) {
|
|
210
360
|
currentBakes = opts.bakes;
|
|
211
361
|
currentWikilinks = opts.wikilinks;
|
|
362
|
+
currentA11y = opts.accessible ? { title: opts.title } : undefined;
|
|
212
363
|
try {
|
|
213
364
|
return unwrapLoneCustomElements(docMarked.parse(text));
|
|
214
365
|
}
|
|
215
366
|
finally {
|
|
216
367
|
currentBakes = undefined;
|
|
217
368
|
currentWikilinks = undefined;
|
|
369
|
+
currentA11y = undefined;
|
|
218
370
|
}
|
|
219
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* A doc's tokens as the accessibility mode renders them, for the build's report: the same
|
|
374
|
+
* lexer and the same rewrites as `renderDocMarkdown`, so the report and the page cannot
|
|
375
|
+
* disagree about what is a figure or which heading is the `<h1>`.
|
|
376
|
+
*/
|
|
377
|
+
export function accessibleDocTokens(text, title) {
|
|
378
|
+
return accessibleTokens([...docMarked.lexer(text)], title);
|
|
379
|
+
}
|
|
220
380
|
/**
|
|
221
381
|
* Derive a clean <meta name="description"> from a doc's first prose paragraph.
|
|
222
382
|
* Skips the title heading, code fences, tables, quotes, html and image/link-only
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
/*
|
|
68
|
+
Pages are audited a few at a time, each in its own tab: one at a time took Learn 3D's 126 pages
|
|
69
|
+
to ~73s (#3585). Results are kept in page order, so the report reads the same either way.
|
|
70
|
+
*/
|
|
71
|
+
const CONCURRENCY = 4;
|
|
72
|
+
const perPage = [];
|
|
73
|
+
try {
|
|
74
|
+
const context = await browser.newContext();
|
|
75
|
+
/*
|
|
76
|
+
The page a reader gets: served from localhost, the doc browser would start its background test
|
|
77
|
+
runner, whose hidden frames and badges no deployed site has.
|
|
78
|
+
*/
|
|
79
|
+
await context.addInitScript(() => localStorage.setItem('tosijs-ui-tests-enabled', 'false'));
|
|
80
|
+
let next = 0;
|
|
81
|
+
const worker = async () => {
|
|
82
|
+
const page = await context.newPage();
|
|
83
|
+
while (next < pages.length) {
|
|
84
|
+
const index = next++;
|
|
85
|
+
const pagePath = pages[index];
|
|
86
|
+
await page.goto(`http://localhost:${server.port}${prefix}${pagePath}`, {
|
|
87
|
+
waitUntil: 'networkidle',
|
|
88
|
+
});
|
|
89
|
+
// Audit the page a reader uses: hydrated when the bundle loads, static if it does not.
|
|
90
|
+
if (await page.$('tosi-doc-system'))
|
|
91
|
+
await page
|
|
92
|
+
.waitForFunction(() => customElements.get('tosi-doc-system'), null, {
|
|
93
|
+
timeout: 10_000,
|
|
94
|
+
})
|
|
95
|
+
.catch(() => undefined);
|
|
96
|
+
/*
|
|
97
|
+
Then until no element has been added or removed for half a second (at most five): live examples render
|
|
98
|
+
after hydration, and a fixed pause audited a busy machine's pages half-built, so the
|
|
99
|
+
findings changed from run to run.
|
|
100
|
+
*/
|
|
101
|
+
await page.evaluate(() => new Promise((resolve) => {
|
|
102
|
+
let timer = setTimeout(done, 500);
|
|
103
|
+
const deadline = setTimeout(done, 5000);
|
|
104
|
+
const observer = new MutationObserver(() => {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
timer = setTimeout(done, 500);
|
|
107
|
+
});
|
|
108
|
+
function done() {
|
|
109
|
+
observer.disconnect();
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
clearTimeout(deadline);
|
|
112
|
+
resolve();
|
|
113
|
+
}
|
|
114
|
+
// Elements arriving, not attributes: an animation changes a style forever.
|
|
115
|
+
observer.observe(document, { subtree: true, childList: true });
|
|
116
|
+
}));
|
|
117
|
+
await page.addScriptTag({ path: axePath });
|
|
118
|
+
const found = await page.evaluate(async (tags) => {
|
|
119
|
+
const result = await window.axe.run(document, {
|
|
120
|
+
runOnly: { type: 'tag', values: tags },
|
|
121
|
+
});
|
|
122
|
+
return result.violations.map((v) => ({
|
|
123
|
+
rule: v.id,
|
|
124
|
+
impact: v.impact,
|
|
125
|
+
help: v.help,
|
|
126
|
+
targets: v.nodes.map((n) => n.target.join(' ')),
|
|
127
|
+
}));
|
|
128
|
+
}, AXE_TAGS);
|
|
129
|
+
perPage[index] = found.map((v) => ({ page: pagePath, ...v }));
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, pages.length) }, worker));
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await browser.close();
|
|
136
|
+
server.stop(true);
|
|
137
|
+
}
|
|
138
|
+
const violations = perPage.flat();
|
|
139
|
+
console.log(JSON.stringify({ violations }));
|