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.
Files changed (37) hide show
  1. package/CHANGELOG.md +45 -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 +145 -1
  10. package/dist/doc-system/site/a11y-audit-cli.d.ts +1 -0
  11. package/dist/doc-system/site/a11y-audit-cli.js +103 -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 +38 -0
  19. package/dist/doc-system/site/epub-a11y.js +119 -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 +70 -5
  26. package/dist/doc-system/site/site-config.d.ts +21 -0
  27. package/dist/drag-and-drop.js +1 -1
  28. package/dist/iife.js +113 -111
  29. package/dist/iife.js.map +12 -12
  30. package/dist/localize.js +2 -2
  31. package/dist/palette.d.ts +2 -0
  32. package/dist/palette.js +38 -6
  33. package/dist/rich-text.js +2 -0
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/llms.txt +1 -1
  37. 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
@@ -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,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
+ }