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.
Files changed (39) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/doc-browser.d.ts +6 -0
  3. package/dist/doc-browser.js +10 -5
  4. package/dist/doc-system/book-html.d.ts +3 -1
  5. package/dist/doc-system/book-html.js +5 -2
  6. package/dist/doc-system/doc-system-styles.js +5 -1
  7. package/dist/doc-system/doc-system.js +4 -0
  8. package/dist/doc-system/render.d.ts +19 -0
  9. package/dist/doc-system/render.js +161 -1
  10. package/dist/doc-system/site/a11y-audit-cli.d.ts +1 -0
  11. package/dist/doc-system/site/a11y-audit-cli.js +139 -0
  12. package/dist/doc-system/site/a11y-audit.d.ts +30 -0
  13. package/dist/doc-system/site/a11y-audit.js +71 -0
  14. package/dist/doc-system/site/a11y-report.d.ts +22 -0
  15. package/dist/doc-system/site/a11y-report.js +138 -0
  16. package/dist/doc-system/site/alt-text.d.ts +31 -0
  17. package/dist/doc-system/site/alt-text.js +55 -0
  18. package/dist/doc-system/site/epub-a11y.d.ts +44 -0
  19. package/dist/doc-system/site/epub-a11y.js +127 -0
  20. package/dist/doc-system/site/epub.d.ts +11 -0
  21. package/dist/doc-system/site/epub.js +84 -19
  22. package/dist/doc-system/site/generate-site.d.ts +3 -1
  23. package/dist/doc-system/site/generate-site.js +33 -1
  24. package/dist/doc-system/site/index.d.ts +2 -0
  25. package/dist/doc-system/site/orchestrator.js +83 -5
  26. package/dist/doc-system/site/sections.d.ts +14 -0
  27. package/dist/doc-system/site/sections.js +34 -12
  28. package/dist/doc-system/site/site-config.d.ts +23 -1
  29. package/dist/drag-and-drop.js +1 -1
  30. package/dist/iife.js +113 -111
  31. package/dist/iife.js.map +12 -12
  32. package/dist/localize.js +2 -2
  33. package/dist/palette.d.ts +2 -0
  34. package/dist/palette.js +38 -6
  35. package/dist/rich-text.js +2 -0
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/llms.txt +1 -1
  39. 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
@@ -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
@@ -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, h2, img, ul, li } = elements;
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), h2(projectName)));
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: #08c; text-decoration: none; }\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: #08c; 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";
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
- a { color: #08c; text-decoration: none; }
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: #08c; font-weight: bold; }
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 h2': {
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 }));