tosijs-ui 1.16.9 → 1.16.11
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 +52 -0
- package/dist/doc-browser.js +14 -1
- package/dist/doc-system/book-html.d.ts +7 -1
- package/dist/doc-system/book-html.js +5 -0
- package/dist/doc-system/doc-system-styles.js +9 -0
- package/dist/doc-system/doc-system.d.ts +17 -0
- package/dist/doc-system/doc-system.js +58 -13
- package/dist/doc-system/heading-id.d.ts +21 -0
- package/dist/doc-system/heading-id.js +62 -0
- package/dist/doc-system/print-window.d.ts +28 -0
- package/dist/doc-system/print-window.js +45 -0
- package/dist/doc-system/render.js +10 -0
- package/dist/doc-system/site/build-warnings.d.ts +1 -0
- package/dist/doc-system/site/build-warnings.js +61 -0
- package/dist/doc-system/site/epub-volumes.d.ts +32 -0
- package/dist/doc-system/site/epub-volumes.js +48 -0
- package/dist/doc-system/site/epub.d.ts +5 -0
- package/dist/doc-system/site/epub.js +14 -7
- package/dist/doc-system/site/generate-site.d.ts +16 -0
- package/dist/doc-system/site/generate-site.js +14 -0
- package/dist/doc-system/site/index.d.ts +1 -1
- package/dist/doc-system/site/index.js +1 -1
- package/dist/doc-system/site/orchestrator.js +16 -2
- package/dist/doc-system/site/single-source.js +19 -3
- package/dist/doc-system/site/site-config.d.ts +5 -0
- package/dist/iife.js +91 -88
- package/dist/iife.js.map +13 -12
- package/dist/live-example/component.d.ts +48 -0
- package/dist/live-example/component.js +180 -22
- package/dist/live-example/styles.d.ts +43 -0
- package/dist/live-example/styles.js +56 -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,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.16.11
|
|
4
|
+
|
|
5
|
+
In-page links work, teaching examples show their test result, and "Download ePub" stops
|
|
6
|
+
offering a book that was never built. `dist/iife.js` grows by 1.2 kB gzip.
|
|
7
|
+
|
|
8
|
+
- **Headings have ids.** Every heading rendered from markdown now carries an `id`, so
|
|
9
|
+
`[text](#that-heading)` and `/page/#that-heading` go somewhere. Before this they were
|
|
10
|
+
links that did nothing, with no error anywhere. The id is the GitHub slug of the heading
|
|
11
|
+
text, with `-1`, `-2` for a repeat on the same page. Arriving at a heading scrolls it to
|
|
12
|
+
the top of the page.
|
|
13
|
+
- **The build warns about an in-page link that matches nothing** on its page, naming the
|
|
14
|
+
page and the anchor. Under `strict: true` it fails the build.
|
|
15
|
+
- **An inset's `#anchor` is matched as the heading's id**, the one the page carries:
|
|
16
|
+
`install.md#whats-new` now matches "What's new", and `#usage-1` names the second "Usage".
|
|
17
|
+
The 1.16.9 spelling still matches (`#what-s-new`).
|
|
18
|
+
- **Inline views show the test result** (#219). An example opened with `{"view": "code"}` or
|
|
19
|
+
`{"view": "console"}` has one line under its preview: the count when its tests pass, the
|
|
20
|
+
first failure in red when they do not. It covers the `test` block and a `tjs` example's
|
|
21
|
+
inline tests, and clicking it opens the tab with the detail.
|
|
22
|
+
- **An example in an inline view always runs its tests**, whatever the page's tests switch says (it is off
|
|
23
|
+
by default away from localhost). A `tjs` example with inline tests therefore transpiles in
|
|
24
|
+
the page when it is in an inline view on a deployed site, where it used to run from the
|
|
25
|
+
copy the build transpiled. Examples without a `view` option are unchanged.
|
|
26
|
+
- **Inline views hide empty tabs.** `html`, `css` and `DOM tests` appear only when the
|
|
27
|
+
example has that block. The full-screen code view still shows them all.
|
|
28
|
+
- **A long section title keeps its arrow.** In the sidebar, a section title that wraps no
|
|
29
|
+
longer leaves the disclosure triangle alone on a line above it.
|
|
30
|
+
- **"Download ePub" follows the build** (#218). The settings menu links to the ePub at the
|
|
31
|
+
URL the build wrote, shows a submenu for a site with several volumes, and has no entry on
|
|
32
|
+
a site that builds none. It used to appear on every site and 404 on those. A hand-written
|
|
33
|
+
`<tosi-doc-system config>` without the new `ebooks` key keeps the old derived link.
|
|
34
|
+
|
|
35
|
+
## 1.16.10
|
|
36
|
+
|
|
37
|
+
Print now opens with the book cover.
|
|
38
|
+
|
|
39
|
+
- **Print as PDF has a cover.** The first page is the default volume's cover: your
|
|
40
|
+
`epub.cover` image, or the one generated from the title and `coverIcon`. The title page and
|
|
41
|
+
contents follow it. Print checks the image loads before using it, so a site without `epub`,
|
|
42
|
+
a build that wrote no cover, or a corpus with no default volume prints as it did before.
|
|
43
|
+
`epub: { printCover: false }` leaves the cover out of Print and keeps it in the ePub.
|
|
44
|
+
- **The cover is a file you can link.** Each ePub build writes its cover beside the book
|
|
45
|
+
(`my-project-cover.png`; an explicit cover keeps its own type), and `/epub-volumes.json`
|
|
46
|
+
gains `coverFilename` and `coverUrl` for every volume whose cover the build wrote. A volume
|
|
47
|
+
without a cover has neither field.
|
|
48
|
+
- A WebP or SVG `epub.cover` is now declared with its own media type in the ePub; it was
|
|
49
|
+
labelled `image/png`.
|
|
50
|
+
- `attachCovers`, `printCoverManifestUrl` and `VOLUME_MANIFEST` are exported from
|
|
51
|
+
`tosijs-ui/site` for a build that does not go through `buildSite`.
|
|
52
|
+
- A configured `epub.cover` that does not exist now prints a warning; the build still
|
|
53
|
+
generates a cover in its place, as before.
|
|
54
|
+
|
|
3
55
|
## 1.16.9
|
|
4
56
|
|
|
5
57
|
Two sets of asks from tjs-lang for *The TJS Programming Language*, a book that teaches
|
package/dist/doc-browser.js
CHANGED
|
@@ -978,9 +978,16 @@ export function createDocBrowser(options) {
|
|
|
978
978
|
const scrollToHashExample = () => {
|
|
979
979
|
if (memoryRouting)
|
|
980
980
|
return;
|
|
981
|
-
|
|
981
|
+
let hash = location.hash.replace(/^#/, '');
|
|
982
982
|
if (!hash)
|
|
983
983
|
return;
|
|
984
|
+
try {
|
|
985
|
+
// A heading id can hold non-ASCII letters, which arrive percent-encoded.
|
|
986
|
+
hash = decodeURIComponent(hash);
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
// a stray % — look it up as written
|
|
990
|
+
}
|
|
984
991
|
requestAnimationFrame(() => {
|
|
985
992
|
let el = null;
|
|
986
993
|
try {
|
|
@@ -991,6 +998,12 @@ export function createDocBrowser(options) {
|
|
|
991
998
|
}
|
|
992
999
|
if (!el)
|
|
993
1000
|
return;
|
|
1001
|
+
// A heading (they have ids as of 1.16.11) goes to the top, where a reader expects a
|
|
1002
|
+
// section to start; an example is centred and flashed.
|
|
1003
|
+
if (/^H[1-6]$/.test(el.tagName)) {
|
|
1004
|
+
el.scrollIntoView({ block: 'start' });
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
994
1007
|
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
995
1008
|
el.classList.add('example-target');
|
|
996
1009
|
setTimeout(() => el && el.classList.remove('example-target'), 1600);
|
|
@@ -9,7 +9,7 @@ export interface BookDoc {
|
|
|
9
9
|
hidden?: boolean;
|
|
10
10
|
}
|
|
11
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";
|
|
12
|
-
export declare const PRINT_CSS = "\n@page { margin: 18mm 16mm; }\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";
|
|
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;
|
|
15
15
|
/** Drop the `<!--{ … }-->` metadata directives the extractor leaves in the text. */
|
|
@@ -18,6 +18,12 @@ export declare function stripDocMeta(text: string): string;
|
|
|
18
18
|
export declare function flatten<T extends NavDoc>(nodes: NavNode<T>[]): NavNode<T>[];
|
|
19
19
|
export interface BookHtmlOptions {
|
|
20
20
|
title: string;
|
|
21
|
+
/**
|
|
22
|
+
* URL of a cover image, printed as the first page. The caller is responsible for it
|
|
23
|
+
* loading (`resolveBookCover` checks before passing it): a broken image here is a blank
|
|
24
|
+
* first page.
|
|
25
|
+
*/
|
|
26
|
+
cover?: string;
|
|
21
27
|
/** full stylesheet (defaults to DEFAULT_BOOK_CSS + PRINT_CSS) */
|
|
22
28
|
css?: string;
|
|
23
29
|
lang?: string;
|
|
@@ -108,6 +108,7 @@ a.wikilink { text-decoration: none; border-bottom: 1px dotted currentColor; }
|
|
|
108
108
|
// Print-only layout: page margins, a chapter per page, an avoid-break TOC.
|
|
109
109
|
export const PRINT_CSS = `
|
|
110
110
|
@page { margin: 18mm 16mm; }
|
|
111
|
+
.book-cover { display: block; margin: 0 auto; max-width: 100%; max-height: 100vh; page-break-after: always; break-inside: avoid; }
|
|
111
112
|
.book-title { text-align: center; margin: 30vh 0 1em; }
|
|
112
113
|
.book-toc { page-break-after: always; }
|
|
113
114
|
.book-toc h2 { page-break-before: avoid; }
|
|
@@ -119,6 +120,7 @@ export const PRINT_CSS = `
|
|
|
119
120
|
export function escapeHtml(s) {
|
|
120
121
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
121
122
|
}
|
|
123
|
+
const escapeAttr = (s) => escapeHtml(s).replace(/"/g, '"');
|
|
122
124
|
export function slugify(s) {
|
|
123
125
|
return s
|
|
124
126
|
.toLowerCase()
|
|
@@ -160,6 +162,9 @@ export function buildBookHtml(docs, opts) {
|
|
|
160
162
|
<style>${css}</style>
|
|
161
163
|
</head>
|
|
162
164
|
<body>
|
|
165
|
+
${opts.cover
|
|
166
|
+
? `<img class="book-cover" src="${escapeAttr(opts.cover)}" alt="${escapeAttr(opts.title)} cover"/>`
|
|
167
|
+
: ''}
|
|
163
168
|
<h1 class="book-title">${escapeHtml(opts.title)}</h1>
|
|
164
169
|
<nav class="book-toc"><h2>Contents</h2><ol>${tocHtml(roots)}</ol></nav>
|
|
165
170
|
${chapters}
|
|
@@ -632,6 +632,15 @@ export function docSystemStyleSpec(theme = {}) {
|
|
|
632
632
|
display: 'inline-block',
|
|
633
633
|
// push the summary text in a bit less than a normal doc-link
|
|
634
634
|
paddingLeft: 'calc(var(--spacing) * 0.5)',
|
|
635
|
+
/*
|
|
636
|
+
Room for the disclosure triangle on the same line (#219). An inline-block as wide as
|
|
637
|
+
its text does not fit beside the marker once the title is long, so the whole link
|
|
638
|
+
dropped to the next line and left the triangle alone on a row of its own, which reads
|
|
639
|
+
as an empty nav entry. Capped, it stays beside the triangle and wraps inside itself.
|
|
640
|
+
*/
|
|
641
|
+
boxSizing: 'border-box',
|
|
642
|
+
maxWidth: 'calc(100% - 1.5em)',
|
|
643
|
+
verticalAlign: 'top',
|
|
635
644
|
},
|
|
636
645
|
// Declarative header link list — shown for no-JS, removed on hydration.
|
|
637
646
|
'.doc-navbar': {
|
|
@@ -2,6 +2,23 @@ import { ElementCreator } from 'tosijs';
|
|
|
2
2
|
import * as tosijsModule from 'tosijs';
|
|
3
3
|
import { Doc } from '../doc-browser.js';
|
|
4
4
|
import './css-var-editor.js';
|
|
5
|
+
export interface Ebook {
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The settings menu's "Download ePub" entry, or `null` for none.
|
|
11
|
+
*
|
|
12
|
+
* The item used to be unconditional and its URL derived from the project name, so a site
|
|
13
|
+
* that built no ePub (the default) shipped a menu item that 404s, and a multi-volume site
|
|
14
|
+
* or one with `basePath` or `epub.title` linked to a file nothing wrote (#218). The site
|
|
15
|
+
* build now bakes the list of volumes it makes into the page config:
|
|
16
|
+
*
|
|
17
|
+
* - a list → one item (or, for several volumes, a submenu), at the build's own URLs
|
|
18
|
+
* - `[]` → the site makes no ePub: no item
|
|
19
|
+
* - no key → a hand-written `config` that predates this: the old derived link, unchanged
|
|
20
|
+
*/
|
|
21
|
+
export declare function epubMenuItem(ebooks: Ebook[] | undefined, legacyUrl: string): Record<string, unknown> | null;
|
|
5
22
|
export declare const CORPUS_ATTEMPTS = 3;
|
|
6
23
|
export declare function fetchCorpus(url: string): Promise<Doc[]>;
|
|
7
24
|
declare const TosiDocSystem_base: tosijsModule.WithAttributes<{
|
|
@@ -62,7 +62,7 @@ import { createDocBrowser, } from '../doc-browser.js';
|
|
|
62
62
|
import { buildSlugMap, legacyQueryPath } from './routing.js';
|
|
63
63
|
import { buildBookHtml, slugify } from './book-html.js';
|
|
64
64
|
import { highlightBlocks } from './highlight.js';
|
|
65
|
-
import { printWhenReady } from './print-window.js';
|
|
65
|
+
import { printWhenReady, resolveBookCover } from './print-window.js';
|
|
66
66
|
import { docSystemStyleSpec } from './doc-system-styles.js';
|
|
67
67
|
import { icons } from '../icons.js';
|
|
68
68
|
import { popMenu } from '../menu.js';
|
|
@@ -71,6 +71,48 @@ import { i18n, setLocale, initLocalization } from '../localize.js';
|
|
|
71
71
|
// tweaker under an example. Part of the doc-system, NOT re-exported from tosijs-ui.
|
|
72
72
|
import './css-var-editor.js';
|
|
73
73
|
const { button, div } = elements;
|
|
74
|
+
const download = (url) => () => {
|
|
75
|
+
const link = document.createElement('a');
|
|
76
|
+
link.href = url;
|
|
77
|
+
link.download = '';
|
|
78
|
+
link.click();
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* The settings menu's "Download ePub" entry, or `null` for none.
|
|
82
|
+
*
|
|
83
|
+
* The item used to be unconditional and its URL derived from the project name, so a site
|
|
84
|
+
* that built no ePub (the default) shipped a menu item that 404s, and a multi-volume site
|
|
85
|
+
* or one with `basePath` or `epub.title` linked to a file nothing wrote (#218). The site
|
|
86
|
+
* build now bakes the list of volumes it makes into the page config:
|
|
87
|
+
*
|
|
88
|
+
* - a list → one item (or, for several volumes, a submenu), at the build's own URLs
|
|
89
|
+
* - `[]` → the site makes no ePub: no item
|
|
90
|
+
* - no key → a hand-written `config` that predates this: the old derived link, unchanged
|
|
91
|
+
*/
|
|
92
|
+
export function epubMenuItem(ebooks, legacyUrl) {
|
|
93
|
+
if (!Array.isArray(ebooks))
|
|
94
|
+
return {
|
|
95
|
+
caption: 'Download ePub',
|
|
96
|
+
icon: 'book',
|
|
97
|
+
action: download(legacyUrl),
|
|
98
|
+
};
|
|
99
|
+
if (ebooks.length === 0)
|
|
100
|
+
return null;
|
|
101
|
+
if (ebooks.length === 1)
|
|
102
|
+
return {
|
|
103
|
+
caption: 'Download ePub',
|
|
104
|
+
icon: 'book',
|
|
105
|
+
action: download(ebooks[0].url),
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
caption: 'Download ePub',
|
|
109
|
+
icon: 'book',
|
|
110
|
+
menuItems: ebooks.map((book) => ({
|
|
111
|
+
caption: book.title,
|
|
112
|
+
action: download(book.url),
|
|
113
|
+
})),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
74
116
|
const PREFS_KEY = 'tosi-doc-system-prefs';
|
|
75
117
|
/*
|
|
76
118
|
Fetch the corpus, retrying a TRANSIENT failure — without becoming the reason it stays down.
|
|
@@ -288,8 +330,13 @@ export class TosiDocSystem extends withAttributes({
|
|
|
288
330
|
const menuItems = [];
|
|
289
331
|
// Print / ePub of the whole corpus, before a separator + the prefs.
|
|
290
332
|
let projectName = '';
|
|
333
|
+
let bookVolumes;
|
|
334
|
+
let ebooks;
|
|
291
335
|
try {
|
|
292
|
-
|
|
336
|
+
const parsed = JSON.parse(this.config || '{}');
|
|
337
|
+
projectName = parsed.projectName || '';
|
|
338
|
+
bookVolumes = parsed.bookVolumes || undefined;
|
|
339
|
+
ebooks = parsed.ebooks;
|
|
293
340
|
}
|
|
294
341
|
catch {
|
|
295
342
|
// ignore — fall through to document.title
|
|
@@ -298,12 +345,16 @@ export class TosiDocSystem extends withAttributes({
|
|
|
298
345
|
menuItems.push({
|
|
299
346
|
caption: 'Print as PDF',
|
|
300
347
|
icon: 'printer',
|
|
301
|
-
action: () => {
|
|
348
|
+
action: async () => {
|
|
349
|
+
// Opened synchronously, inside the click, or a popup blocker refuses it.
|
|
302
350
|
const win = window.open('', '_blank');
|
|
303
351
|
if (!win) {
|
|
304
352
|
window.alert('Allow pop-ups to print the documentation as a book.');
|
|
305
353
|
return;
|
|
306
354
|
}
|
|
355
|
+
// Checked before the book is written: a cover only if the build wrote one
|
|
356
|
+
// and it loads. See `resolveBookCover`.
|
|
357
|
+
const bookCover = await resolveBookCover(bookVolumes);
|
|
307
358
|
win.document.open();
|
|
308
359
|
/*
|
|
309
360
|
`autoPrint: false` — highlight BEFORE printing (review major M1).
|
|
@@ -319,6 +370,7 @@ export class TosiDocSystem extends withAttributes({
|
|
|
319
370
|
*/
|
|
320
371
|
win.document.write(buildBookHtml(this.corpus, {
|
|
321
372
|
title: bookTitle,
|
|
373
|
+
cover: bookCover,
|
|
322
374
|
autoPrint: false,
|
|
323
375
|
}));
|
|
324
376
|
win.document.close();
|
|
@@ -337,16 +389,9 @@ export class TosiDocSystem extends withAttributes({
|
|
|
337
389
|
void printWhenReady(win, () => highlightBlocks(win.document, { policy: 'none' }));
|
|
338
390
|
},
|
|
339
391
|
});
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
action: () => {
|
|
344
|
-
const link = document.createElement('a');
|
|
345
|
-
link.href = `/${slugify(bookTitle)}.epub`;
|
|
346
|
-
link.download = '';
|
|
347
|
-
link.click();
|
|
348
|
-
},
|
|
349
|
-
});
|
|
392
|
+
const epubItem = epubMenuItem(ebooks, `/${slugify(bookTitle)}.epub`);
|
|
393
|
+
if (epubItem)
|
|
394
|
+
menuItems.push(epubItem);
|
|
350
395
|
menuItems.push(null);
|
|
351
396
|
const localeOptions = i18n.localeOptions.value || [];
|
|
352
397
|
if (localeOptions.length > 1) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The text a reader sees in a heading: marked's inline tokens with the markup dropped. */
|
|
2
|
+
export declare function headingText(token: any): string;
|
|
3
|
+
/** GitHub-flavoured slug of some heading text. May be empty (a heading of punctuation). */
|
|
4
|
+
export declare function slugOfHeadingText(text: string): string;
|
|
5
|
+
/** The slug of a marked heading token, before any `-1` de-duplication. */
|
|
6
|
+
export declare const headingSlug: (token: any) => string;
|
|
7
|
+
/**
|
|
8
|
+
* Hands out ids for one page, suffixing a repeat the way GitHub does.
|
|
9
|
+
*
|
|
10
|
+
* It knows about headings and nothing else. In particular it does NOT keep headings out of
|
|
11
|
+
* `example-N`, the ids live examples get in the browser: a reservation here was tried for
|
|
12
|
+
* 1.16.11 and made `next('example')` loop forever on its second call, because every suffix
|
|
13
|
+
* of `example` is `example-N`. A heading "Example 2" is `example-2`, as on GitHub. On a page
|
|
14
|
+
* that also has a second live example the two share that id, and a link reaches whichever
|
|
15
|
+
* comes first in the document (a known overlap, not checked by the build).
|
|
16
|
+
*/
|
|
17
|
+
export declare class HeadingIds {
|
|
18
|
+
private seen;
|
|
19
|
+
/** The id for the next heading with this slug, or '' when the slug is empty. */
|
|
20
|
+
next(slug: string): string;
|
|
21
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
THE rule for a heading's id, build and client alike.
|
|
3
|
+
|
|
4
|
+
Headings rendered with no `id`, so `[text](#some-heading)` was a live-looking link that did
|
|
5
|
+
nothing, with no error anywhere (board #3178). The slug is the GitHub-flavoured one, because
|
|
6
|
+
that is what an author writing an in-page link expects and what the same file gets when it is
|
|
7
|
+
read on GitHub: lowercase, every character that is not a letter, a number, a space, `-` or
|
|
8
|
+
`_` dropped, spaces to `-`. A repeat on one page gets `-1`, `-2`, ….
|
|
9
|
+
|
|
10
|
+
The renderer stamps ids with it and an inset's `#anchor` (single-source.ts) is matched with
|
|
11
|
+
it. Do not write a second copy: an anchor that the build accepts and the page does not carry
|
|
12
|
+
is exactly the silent failure this exists to end.
|
|
13
|
+
*/
|
|
14
|
+
/** The text a reader sees in a heading: marked's inline tokens with the markup dropped. */
|
|
15
|
+
export function headingText(token) {
|
|
16
|
+
if (Array.isArray(token.tokens) && token.type !== 'codespan')
|
|
17
|
+
return token.tokens.map(headingText).join('');
|
|
18
|
+
if (token.type === 'html')
|
|
19
|
+
return '';
|
|
20
|
+
if (token.type === 'br')
|
|
21
|
+
return ' ';
|
|
22
|
+
return String(token.text ?? '');
|
|
23
|
+
}
|
|
24
|
+
/** GitHub-flavoured slug of some heading text. May be empty (a heading of punctuation). */
|
|
25
|
+
export function slugOfHeadingText(text) {
|
|
26
|
+
return text
|
|
27
|
+
.replace(/&(?:#\d+|#x[\da-f]+|[a-z][a-z\d]*);/gi, '') // an entity is not its name
|
|
28
|
+
.trim()
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, '')
|
|
31
|
+
.replace(/\s/g, '-');
|
|
32
|
+
}
|
|
33
|
+
/** The slug of a marked heading token, before any `-1` de-duplication. */
|
|
34
|
+
export const headingSlug = (token) => slugOfHeadingText(headingText(token));
|
|
35
|
+
/**
|
|
36
|
+
* Hands out ids for one page, suffixing a repeat the way GitHub does.
|
|
37
|
+
*
|
|
38
|
+
* It knows about headings and nothing else. In particular it does NOT keep headings out of
|
|
39
|
+
* `example-N`, the ids live examples get in the browser: a reservation here was tried for
|
|
40
|
+
* 1.16.11 and made `next('example')` loop forever on its second call, because every suffix
|
|
41
|
+
* of `example` is `example-N`. A heading "Example 2" is `example-2`, as on GitHub. On a page
|
|
42
|
+
* that also has a second live example the two share that id, and a link reaches whichever
|
|
43
|
+
* comes first in the document (a known overlap, not checked by the build).
|
|
44
|
+
*/
|
|
45
|
+
export class HeadingIds {
|
|
46
|
+
seen = new Map();
|
|
47
|
+
/** The id for the next heading with this slug, or '' when the slug is empty. */
|
|
48
|
+
next(slug) {
|
|
49
|
+
if (!slug)
|
|
50
|
+
return '';
|
|
51
|
+
let id = slug;
|
|
52
|
+
// A suffixed id can itself collide with an earlier literal heading ("Usage 1"). Each
|
|
53
|
+
// pass raises the counter, and `seen` is finite, so this ends.
|
|
54
|
+
while (this.seen.has(id)) {
|
|
55
|
+
const n = (this.seen.get(slug) ?? 0) + 1;
|
|
56
|
+
this.seen.set(slug, n);
|
|
57
|
+
id = `${slug}-${n}`;
|
|
58
|
+
}
|
|
59
|
+
this.seen.set(id, this.seen.get(id) ?? 0);
|
|
60
|
+
return id;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -28,3 +28,31 @@ export interface PrintWhenReadyOptions {
|
|
|
28
28
|
* document.
|
|
29
29
|
*/
|
|
30
30
|
export declare function printWhenReady(win: PrintableWindow, highlight: () => Promise<unknown>, opts?: PrintWhenReadyOptions): Promise<void>;
|
|
31
|
+
export interface ResolveBookCoverOptions {
|
|
32
|
+
/** Injected for tests. */
|
|
33
|
+
fetchFn?: (url: string) => Promise<{
|
|
34
|
+
ok: boolean;
|
|
35
|
+
json: () => Promise<unknown>;
|
|
36
|
+
}>;
|
|
37
|
+
/** Resolves when the image at `url` loads, rejects when it does not. Injected for tests. */
|
|
38
|
+
loadImage?: (url: string) => Promise<unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* How long the whole lookup may take before Print goes ahead without a cover. The files
|
|
41
|
+
* are small and same-origin, so this only bites on a stalled connection.
|
|
42
|
+
*/
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The cover Print should open with, or `undefined` for none.
|
|
47
|
+
*
|
|
48
|
+
* Reads the default volume's `coverUrl` from the volume manifest the site build wrote, then
|
|
49
|
+
* loads the image before answering. Both steps are checks on purpose: the manifest lists a
|
|
50
|
+
* cover only when the build wrote one, and loading it here means the printed book never
|
|
51
|
+
* starts with a broken image and a page break — without an inline `onerror`, which a
|
|
52
|
+
* Content-Security-Policy would block in the popup.
|
|
53
|
+
*
|
|
54
|
+
* Any failure means no cover, and so does taking too long: the popup is already open and
|
|
55
|
+
* blank while this runs, and a request that never settles must not leave it that way. Print
|
|
56
|
+
* must still happen.
|
|
57
|
+
*/
|
|
58
|
+
export declare function resolveBookCover(manifestUrl: string | undefined, opts?: ResolveBookCoverOptions): Promise<string | undefined>;
|
|
@@ -14,6 +14,7 @@ The two changes, so the shape is not rediscovered:
|
|
|
14
14
|
Both failure modes are silent in the worst way: a print dialog that opens over a half-rendered
|
|
15
15
|
page, or one that never opens at all. Neither throws, and you only find out by printing.
|
|
16
16
|
*/
|
|
17
|
+
import { DEFAULT_BOOK } from './book-target.js';
|
|
17
18
|
/**
|
|
18
19
|
* Highlight, then print — after the window has actually loaded.
|
|
19
20
|
*
|
|
@@ -48,3 +49,47 @@ export function printWhenReady(win, highlight, opts = {}) {
|
|
|
48
49
|
win.addEventListener('load', go, { once: true });
|
|
49
50
|
});
|
|
50
51
|
}
|
|
52
|
+
const loadImageInPage = (url) => new Promise((resolve, reject) => {
|
|
53
|
+
const img = new Image();
|
|
54
|
+
img.onload = resolve;
|
|
55
|
+
img.onerror = reject;
|
|
56
|
+
img.src = url;
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* The cover Print should open with, or `undefined` for none.
|
|
60
|
+
*
|
|
61
|
+
* Reads the default volume's `coverUrl` from the volume manifest the site build wrote, then
|
|
62
|
+
* loads the image before answering. Both steps are checks on purpose: the manifest lists a
|
|
63
|
+
* cover only when the build wrote one, and loading it here means the printed book never
|
|
64
|
+
* starts with a broken image and a page break — without an inline `onerror`, which a
|
|
65
|
+
* Content-Security-Policy would block in the popup.
|
|
66
|
+
*
|
|
67
|
+
* Any failure means no cover, and so does taking too long: the popup is already open and
|
|
68
|
+
* blank while this runs, and a request that never settles must not leave it that way. Print
|
|
69
|
+
* must still happen.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveBookCover(manifestUrl, opts = {}) {
|
|
72
|
+
if (!manifestUrl)
|
|
73
|
+
return Promise.resolve(undefined);
|
|
74
|
+
const { fetchFn = fetch, loadImage = loadImageInPage, timeoutMs = 3000, } = opts;
|
|
75
|
+
const lookup = async () => {
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetchFn(manifestUrl);
|
|
78
|
+
if (!response.ok)
|
|
79
|
+
return undefined;
|
|
80
|
+
const volumes = (await response.json());
|
|
81
|
+
const cover = volumes.find((v) => v.book === DEFAULT_BOOK)?.coverUrl;
|
|
82
|
+
if (!cover)
|
|
83
|
+
return undefined;
|
|
84
|
+
await loadImage(cover);
|
|
85
|
+
return cover;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return Promise.race([
|
|
92
|
+
lookup(),
|
|
93
|
+
new Promise((resolve) => setTimeout(() => resolve(undefined), timeoutMs)),
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
@@ -10,11 +10,20 @@ 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
14
|
const baseRenderer = new Renderer();
|
|
14
15
|
let currentBakes;
|
|
16
|
+
// Per-parse, reset in `preprocess` below (same pattern as the footnote state).
|
|
17
|
+
let headingIds = new HeadingIds();
|
|
15
18
|
const docMarked = new Marked();
|
|
16
19
|
docMarked.use({
|
|
17
20
|
renderer: {
|
|
21
|
+
// Every heading gets an id, so `[text](#that-heading)` resolves — see heading-id.ts.
|
|
22
|
+
heading(token) {
|
|
23
|
+
const id = headingIds.next(headingSlug(token));
|
|
24
|
+
const inner = this.parser.parseInline(token.tokens);
|
|
25
|
+
return `<h${token.depth}${id ? ` id="${escapeAttr(id)}"` : ''}>${inner}</h${token.depth}>\n`;
|
|
26
|
+
},
|
|
18
27
|
code(token) {
|
|
19
28
|
// Fence info grammar: `<lang>` optionally with a `:<mode>` and/or `#<id>` in
|
|
20
29
|
// EITHER order — `js`, `css#anchor`, `js:iframe`, `ts:ide#demo`, `ts#demo:ide`.
|
|
@@ -84,6 +93,7 @@ docMarked.use({
|
|
|
84
93
|
preprocess(markdown) {
|
|
85
94
|
footnoteDefs = new Map();
|
|
86
95
|
footnoteOrder = [];
|
|
96
|
+
headingIds = new HeadingIds();
|
|
87
97
|
return markdown;
|
|
88
98
|
},
|
|
89
99
|
postprocess(html) {
|
|
@@ -21,3 +21,4 @@ export declare function misconfigured(strict: boolean | undefined, message: stri
|
|
|
21
21
|
export declare function liveFenceLanguages(docs: Array<{
|
|
22
22
|
text?: string;
|
|
23
23
|
}>, policy?: ExamplePolicy): Set<string>;
|
|
24
|
+
export declare function deadInPageAnchors(html: string): string[];
|
|
@@ -43,3 +43,64 @@ export function liveFenceLanguages(docs, policy = 'auto') {
|
|
|
43
43
|
}
|
|
44
44
|
return langs;
|
|
45
45
|
}
|
|
46
|
+
const unescapeAttr = (s) => s
|
|
47
|
+
.replace(/"/g, '"')
|
|
48
|
+
.replace(/'/g, "'")
|
|
49
|
+
.replace(/</g, '<')
|
|
50
|
+
.replace(/>/g, '>')
|
|
51
|
+
.replace(/&/g, '&');
|
|
52
|
+
/**
|
|
53
|
+
* The in-page links (`href="#…"`) in a RENDERED page that point at no id on it.
|
|
54
|
+
*
|
|
55
|
+
* A dead in-page anchor is the one broken link a reader cannot tell from a working one: it
|
|
56
|
+
* is styled as a link, it does nothing, and nothing reports it (board #3178). Asked of the
|
|
57
|
+
* rendered HTML and not of the markdown, so the ids are the ones the page really carries
|
|
58
|
+
* (`renderDocMarkdown`, THE renderer) and no second copy of the heading-id rule exists here.
|
|
59
|
+
*
|
|
60
|
+
* Not reported, because they are not in-page anchors or resolve only in the browser:
|
|
61
|
+
* `#/route` and `#!…` (hash routers), a bare `#`, `#top`, and `#example-N` (live examples
|
|
62
|
+
* are numbered when the page hydrates). A fence's own `#id` is on the rendered page and is
|
|
63
|
+
* checked like any other id.
|
|
64
|
+
*
|
|
65
|
+
* Tags are read with their attributes in any order and any quoting. What it cannot see is
|
|
66
|
+
* an id that only exists once a script has run; such a link is reported.
|
|
67
|
+
*/
|
|
68
|
+
// One HTML start tag, attributes and all. Quoted values may hold `>`, which is why this is
|
|
69
|
+
// not `<[^>]*>`.
|
|
70
|
+
const START_TAG = /<([a-z][a-z0-9-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/gi;
|
|
71
|
+
const ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
72
|
+
export function deadInPageAnchors(html) {
|
|
73
|
+
const ids = new Set();
|
|
74
|
+
const links = [];
|
|
75
|
+
for (const tag of html.matchAll(START_TAG)) {
|
|
76
|
+
const name = tag[1].toLowerCase();
|
|
77
|
+
for (const attr of tag[2].matchAll(ATTRIBUTE)) {
|
|
78
|
+
const key = attr[1].toLowerCase();
|
|
79
|
+
const value = unescapeAttr(attr[2] ?? attr[3] ?? attr[4] ?? '');
|
|
80
|
+
if (key === 'id' || key === 'data-example-id')
|
|
81
|
+
ids.add(value);
|
|
82
|
+
// `name` is an anchor target on <a> only; on a form field it is a field name.
|
|
83
|
+
else if (key === 'name' && name === 'a')
|
|
84
|
+
ids.add(value);
|
|
85
|
+
else if (key === 'href' && name === 'a' && value.startsWith('#'))
|
|
86
|
+
links.push(value.slice(1));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const dead = new Set();
|
|
90
|
+
for (let target of links) {
|
|
91
|
+
try {
|
|
92
|
+
target = decodeURIComponent(target);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// a stray % — compare it as written
|
|
96
|
+
}
|
|
97
|
+
if (!target ||
|
|
98
|
+
target === 'top' ||
|
|
99
|
+
/^[/!]/.test(target) ||
|
|
100
|
+
/^example-\d+$/.test(target) ||
|
|
101
|
+
ids.has(target))
|
|
102
|
+
continue;
|
|
103
|
+
dead.add(target);
|
|
104
|
+
}
|
|
105
|
+
return [...dead];
|
|
106
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Where the volume manifest is served. */
|
|
2
|
+
export declare const VOLUME_MANIFEST = "epub-volumes.json";
|
|
1
3
|
export interface EpubVolume {
|
|
2
4
|
/** the `book` value, or '' for the default volume */
|
|
3
5
|
book: string;
|
|
@@ -7,13 +9,38 @@ export interface EpubVolume {
|
|
|
7
9
|
filename: string;
|
|
8
10
|
/** served URL, honouring basePath */
|
|
9
11
|
url: string;
|
|
12
|
+
/**
|
|
13
|
+
* The cover image the build wrote beside the ePub, e.g. `my-project-cover.png`. Present
|
|
14
|
+
* only in the manifest, and only when that file exists: it is read back from the output
|
|
15
|
+
* dir after the ePubs are built (`attachCovers`), never predicted.
|
|
16
|
+
*/
|
|
17
|
+
coverFilename?: string;
|
|
18
|
+
/** served URL of that cover, honouring basePath — Print uses it as its first page */
|
|
19
|
+
coverUrl?: string;
|
|
10
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The image types a cover may be, by extension. One table: the ePub build labels its cover
|
|
23
|
+
* from it and `attachCovers` recognises a cover file by it.
|
|
24
|
+
*/
|
|
25
|
+
export declare const COVER_MEDIA_TYPES: Record<string, string>;
|
|
26
|
+
/** The name `buildEpub` gives a cover written beside `<stem>.epub`, minus its extension. */
|
|
27
|
+
export declare const coverStem: (epubFilename: string) => string;
|
|
28
|
+
/**
|
|
29
|
+
* Add `coverFilename`/`coverUrl` to each volume whose cover is among `files` (a listing of
|
|
30
|
+
* the output dir, taken AFTER the ePubs were built).
|
|
31
|
+
*
|
|
32
|
+
* Read back and not derived, because only the ePub build knows what it wrote: an explicit
|
|
33
|
+
* cover keeps its own type, a missing one falls back to a generated PNG, and a build without
|
|
34
|
+
* `@resvg/resvg-js` writes none. A predicted name was wrong in each of those cases.
|
|
35
|
+
*/
|
|
36
|
+
export declare function attachCovers(volumes: EpubVolume[], files: string[], basePath?: string): EpubVolume[];
|
|
11
37
|
export interface VolumeNamingConfig {
|
|
12
38
|
name?: string;
|
|
13
39
|
basePath?: string;
|
|
14
40
|
epub?: boolean | {
|
|
15
41
|
title?: string;
|
|
16
42
|
volumeTitles?: Record<string, string>;
|
|
43
|
+
printCover?: boolean;
|
|
17
44
|
};
|
|
18
45
|
}
|
|
19
46
|
/**
|
|
@@ -47,3 +74,8 @@ export declare const EPUB_DOWNLOADS_MARKER: RegExp;
|
|
|
47
74
|
* comment.
|
|
48
75
|
*/
|
|
49
76
|
export declare function renderEpubDownloads(text: string, volumes: EpubVolume[]): string;
|
|
77
|
+
/**
|
|
78
|
+
* The manifest URL a page should hand to Print so it can open with the cover, or
|
|
79
|
+
* `undefined` when it should not: no ePub is built, or `epub.printCover` is `false`.
|
|
80
|
+
*/
|
|
81
|
+
export declare function printCoverManifestUrl(config: VolumeNamingConfig): string | undefined;
|