tosijs-ui 1.16.8 → 1.16.10

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 (34) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +1 -1
  3. package/dist/doc-system/book-html.d.ts +9 -1
  4. package/dist/doc-system/book-html.js +7 -1
  5. package/dist/doc-system/book-target.d.ts +24 -0
  6. package/dist/doc-system/book-target.js +62 -3
  7. package/dist/doc-system/doc-system.js +11 -3
  8. package/dist/doc-system/print-window.d.ts +28 -0
  9. package/dist/doc-system/print-window.js +45 -0
  10. package/dist/doc-system/site/docs.d.ts +13 -0
  11. package/dist/doc-system/site/docs.js +35 -3
  12. package/dist/doc-system/site/epub-volumes.d.ts +32 -0
  13. package/dist/doc-system/site/epub-volumes.js +48 -0
  14. package/dist/doc-system/site/epub.d.ts +5 -0
  15. package/dist/doc-system/site/epub.js +38 -11
  16. package/dist/doc-system/site/generate-site.d.ts +5 -0
  17. package/dist/doc-system/site/generate-site.js +1 -0
  18. package/dist/doc-system/site/index.d.ts +4 -1
  19. package/dist/doc-system/site/index.js +5 -1
  20. package/dist/doc-system/site/orchestrator.js +17 -2
  21. package/dist/doc-system/site/single-source.d.ts +31 -0
  22. package/dist/doc-system/site/single-source.js +377 -0
  23. package/dist/doc-system/site/site-config.d.ts +5 -0
  24. package/dist/iife.js +94 -90
  25. package/dist/iife.js.map +11 -10
  26. package/dist/live-example/component.d.ts +26 -0
  27. package/dist/live-example/component.js +195 -6
  28. package/dist/live-example/dialects.d.ts +4 -0
  29. package/dist/live-example/styles.d.ts +17 -0
  30. package/dist/live-example/styles.js +28 -0
  31. package/dist/version.d.ts +1 -1
  32. package/dist/version.js +1 -1
  33. package/llms.txt +1 -1
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,93 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.10
4
+
5
+ Print now opens with the book cover.
6
+
7
+ - **Print as PDF has a cover.** The first page is the default volume's cover: your
8
+ `epub.cover` image, or the one generated from the title and `coverIcon`. The title page and
9
+ contents follow it. Print checks the image loads before using it, so a site without `epub`,
10
+ a build that wrote no cover, or a corpus with no default volume prints as it did before.
11
+ `epub: { printCover: false }` leaves the cover out of Print and keeps it in the ePub.
12
+ - **The cover is a file you can link.** Each ePub build writes its cover beside the book
13
+ (`my-project-cover.png`; an explicit cover keeps its own type), and `/epub-volumes.json`
14
+ gains `coverFilename` and `coverUrl` for every volume whose cover the build wrote. A volume
15
+ without a cover has neither field.
16
+ - A WebP or SVG `epub.cover` is now declared with its own media type in the ePub; it was
17
+ labelled `image/png`.
18
+ - `attachCovers`, `printCoverManifestUrl` and `VOLUME_MANIFEST` are exported from
19
+ `tosijs-ui/site` for a build that does not go through `buildSite`.
20
+ - A configured `epub.cover` that does not exist now prints a warning; the build still
21
+ generates a cover in its place, as before.
22
+
23
+ ## 1.16.9
24
+
25
+ Two sets of asks from tjs-lang for *The TJS Programming Language*, a book that teaches
26
+ programming in the page. Single-sourcing for books (#217): one corpus, several outputs,
27
+ nothing kept in sync by hand. And live examples that open with their code showing and a
28
+ console that speaks the example's language (#216). Additive, with one behaviour change to
29
+ know about: in a `tjs` or `ts` example, or one in a registered dialect, a line typed at the
30
+ console is now evaluated in that language and not as JavaScript. `dist/iife.js` grows by
31
+ 0.7 kB gzip.
32
+
33
+ ### The book opens on the project, not on "Adopting the doc system"
34
+
35
+ Both pages are pinned to the top, and the adopting page had an `order` where the README had
36
+ none, so it sorted first: the ePub's opening chapter, and the first entry in the site nav, was
37
+ a page about adopting the doc system. The README now leads both.
38
+
39
+ ### One source, several outputs (#217)
40
+
41
+ - **Per-volume placement.** A page can sit in a different place in each book:
42
+ `{"placement": {"language": {"parent": "for-programmers", "order": 20}}}` in its metadata.
43
+ Each entry may set `parent`, `order`, `pin` and `title`, and naming a volume also binds the
44
+ page into it. The site nav is unaffected.
45
+ - **Text insets.** `<!--{ "inset": "install.md#with-bun" }-->` includes another document, or
46
+ one heading's section of it, in the page. A fragment (a markdown file whose name starts
47
+ with `_`, in a directory the docs are extracted from) can be inset without being a page, and is the only kind of
48
+ file an inset reads. Resolved at extraction, so the site, the ePubs, `llms.txt` and the markdown copies
49
+ all get the assembled text, and an inset passage's live examples and tests run where it is
50
+ inset.
51
+ - **Conditional text.** `<!--{ "only": "book" | "site" }--> … <!--{ "end": "only" }-->`
52
+ conditional text, for wording that belongs to one output, such as "next chapter" against
53
+ "next page". Books read `bookText`, everything else reads `text`; both are in the published
54
+ `docs.json`, so this chooses wording and does not hide anything.
55
+ - **Each is guarded.** An inset that resolves nowhere, an include cycle, a missing heading, a
56
+ file that is not a fragment, an unbalanced `only`, a directive inside a list, quote or
57
+ paragraph, and a placement under a parent that is not in the volume all fail the build.
58
+ - **Print is not a volume.** The in-app Print output takes the book wording but stays the
59
+ whole site in site order; `placement` arranges ePub volumes.
60
+ - New from `tosijs-ui/site`, for a consumer that builds its corpus some other way:
61
+ `assembleCorpus`, `resolveInsets`, `splitConditions`, `SingleSourceError`, `placeInBook`,
62
+ `bookTextOf`, and the `BookPlacement` type.
63
+ - Saving an edited live example back to source is refused on a page whose source uses an
64
+ inset or conditional text, where the example's position on the page no longer identifies a
65
+ block in that file. No existing page is affected: it applies only to a page that uses one
66
+ of the new directives.
67
+
68
+ ### Examples that open with their code showing, and a console in their language (#216)
69
+
70
+ - **A fence picks the view an example opens in.** `{"view": "code"}` in the source block's
71
+ fence options opens the editor beside the preview, in the page and not full-screen;
72
+ `{"view": "console"}` puts the console where the preview would be, beside the code, for
73
+ examples whose point is a value. The editor is built as the example nears the viewport. In
74
+ a narrow column the panes stack. `example.showInline('code' | 'console')` does the same
75
+ from code.
76
+ - **The console evaluates in the example's dialect.** A line goes through the same transform
77
+ as the example's code, with the same fence options, so `x == '5'` in a `tjs` example is
78
+ TJS's `==`. A line the dialect rejects shows the dialect's error and is not retried as
79
+ JavaScript. Plain `js` examples and `run` dialects are unchanged. A registered dialect's
80
+ `transform` now also receives console lines, marked with `options.repl === true`.
81
+ - **Known limit of the above with tjs.** tjs puts setup statements in front of the line it
82
+ translates, so an expression shows its value, but a line that is only a declaration shows a
83
+ leftover value where a browser console shows `undefined`, and a line needing both that
84
+ setup and `await` shows `undefined`. Fixing it needs the setup to be separable, which is
85
+ asked of tjs-lang.
86
+ - The generated JavaScript tjs-lang asked to see already exists as the read-only **JS** tab of
87
+ a `tjs` or `ts` example, and the inline views make it reachable in the page. Showing the
88
+ JavaScript for a single console line is not in this release: a transform's output for one
89
+ line is mostly its runtime prelude, and there is no general way to separate the two.
90
+
3
91
  ## 1.16.8
4
92
 
5
93
  A patch for honest results. The doc-test lane can no longer pass over pages that never
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # tosijs-ui
2
2
 
3
- <!--{ "pin": "top", "layout": "full-width", "headTitle": "tosijs-ui — robust, standards-based web components", "description": "A small, fast library of standards-based web components (data tables, dialogs, forms, rich text, carousels, and more) built on tosijs — smaller and faster than a React app, with no virtual DOM.", "keywords": "web components, custom elements, tosijs, data table, dialog, form, rich text, no virtual dom" }-->
3
+ <!--{ "pin": "top", "order": 0, "layout": "full-width", "headTitle": "tosijs-ui — robust, standards-based web components", "description": "A small, fast library of standards-based web components (data tables, dialogs, forms, rich text, carousels, and more) built on tosijs — smaller and faster than a React app, with no virtual DOM.", "keywords": "web components, custom elements, tosijs, data table, dialog, form, rich text, no virtual dom" }-->
4
4
 
5
5
  [ui.tosijs.net live demo](https://ui.tosijs.net) | [tosijs](https://tosijs.net) | [discord](https://discord.gg/ramJ9rgky5) | [github](https://github.com/tonioloewald/tosijs-ui#readme) | [npm](https://www.npmjs.com/package/tosijs-ui)
6
6
 
@@ -3,11 +3,13 @@ export interface BookDoc {
3
3
  filename: string;
4
4
  title: string;
5
5
  text: string;
6
+ /** the book's variant, where conditional text made it differ from the site's */
7
+ bookText?: string;
6
8
  parent?: string;
7
9
  hidden?: boolean;
8
10
  }
9
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";
10
- 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";
11
13
  export declare function escapeHtml(s: string): string;
12
14
  export declare function slugify(s: string): string;
13
15
  /** Drop the `<!--{ … }-->` metadata directives the extractor leaves in the text. */
@@ -16,6 +18,12 @@ export declare function stripDocMeta(text: string): string;
16
18
  export declare function flatten<T extends NavDoc>(nodes: NavNode<T>[]): NavNode<T>[];
17
19
  export interface BookHtmlOptions {
18
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;
19
27
  /** full stylesheet (defaults to DEFAULT_BOOK_CSS + PRINT_CSS) */
20
28
  css?: string;
21
29
  lang?: string;
@@ -9,6 +9,7 @@ force-wrapped code listings, not executed.
9
9
  import { renderDocMarkdown } from './render.js';
10
10
  import { buildSlugMap } from './routing.js';
11
11
  import { buildNavTree } from './nav-tree.js';
12
+ import { bookTextOf } from './book-target.js';
12
13
  // Clean book typography; force-wraps code so listings never overflow a page.
13
14
  export const DEFAULT_BOOK_CSS = `/* doc-system book stylesheet */
14
15
  html { font-size: 100%; }
@@ -107,6 +108,7 @@ a.wikilink { text-decoration: none; border-bottom: 1px dotted currentColor; }
107
108
  // Print-only layout: page margins, a chapter per page, an avoid-break TOC.
108
109
  export const PRINT_CSS = `
109
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; }
110
112
  .book-title { text-align: center; margin: 30vh 0 1em; }
111
113
  .book-toc { page-break-after: always; }
112
114
  .book-toc h2 { page-break-before: avoid; }
@@ -118,6 +120,7 @@ export const PRINT_CSS = `
118
120
  export function escapeHtml(s) {
119
121
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
120
122
  }
123
+ const escapeAttr = (s) => escapeHtml(s).replace(/"/g, '&quot;');
121
124
  export function slugify(s) {
122
125
  return s
123
126
  .toLowerCase()
@@ -149,7 +152,7 @@ export function buildBookHtml(docs, opts) {
149
152
  const roots = buildNavTree(visible, slugMap);
150
153
  const css = opts.css ?? `${DEFAULT_BOOK_CSS}\n${PRINT_CSS}`;
151
154
  const chapters = flatten(roots)
152
- .map((node) => `<section class="chapter" id="${slugify(node.doc.filename)}">\n${renderDocMarkdown(stripDocMeta(node.doc.text))}\n</section>`)
155
+ .map((node) => `<section class="chapter" id="${slugify(node.doc.filename)}">\n${renderDocMarkdown(stripDocMeta(bookTextOf(node.doc)))}\n</section>`)
153
156
  .join('\n');
154
157
  return `<!DOCTYPE html>
155
158
  <html lang="${opts.lang ?? 'en'}">
@@ -159,6 +162,9 @@ export function buildBookHtml(docs, opts) {
159
162
  <style>${css}</style>
160
163
  </head>
161
164
  <body>
165
+ ${opts.cover
166
+ ? `<img class="book-cover" src="${escapeAttr(opts.cover)}" alt="${escapeAttr(opts.title)} cover"/>`
167
+ : ''}
162
168
  <h1 class="book-title">${escapeHtml(opts.title)}</h1>
163
169
  <nav class="book-toc"><h2>Contents</h2><ol>${tocHtml(roots)}</ol></nav>
164
170
  ${chapters}
@@ -14,6 +14,16 @@ export interface BookTargetDoc {
14
14
  /** one book, several books, or `'none'` */
15
15
  book?: string | string[];
16
16
  hidden?: boolean;
17
+ /** where this doc sits in a particular volume, keyed by `book` name */
18
+ placement?: Record<string, BookPlacement>;
19
+ }
20
+ export interface BookPlacement {
21
+ /** parent within this volume; `''` puts the doc at the top level */
22
+ parent?: string;
23
+ order?: number;
24
+ pin?: 'top' | 'bottom';
25
+ /** chapter title in this volume */
26
+ title?: string;
17
27
  }
18
28
  type SlugMap = Record<string, string>;
19
29
  /**
@@ -29,6 +39,15 @@ export declare function chain<T extends BookTargetDoc>(doc: T, docs: T[], slugMa
29
39
  * another, or bind into several (`["default", "appendices"]`).
30
40
  */
31
41
  export declare function resolveBooks<T extends BookTargetDoc>(doc: T, docs: T[], slugMap?: SlugMap): string[];
42
+ /**
43
+ * The docs of one volume, arranged for it: each doc's `placement` for this book overlaid on
44
+ * its site `parent` / `order` / `pin` / `title`. Returns shallow copies; the site's
45
+ * arrangement is untouched.
46
+ */
47
+ export declare function placeInBook<T extends BookTargetDoc & {
48
+ order?: number;
49
+ pin?: 'top' | 'bottom';
50
+ }>(docs: T[], book: string): T[];
32
51
  /**
33
52
  * Hidden here or anywhere above.
34
53
  *
@@ -48,4 +67,9 @@ export declare function withoutHidden<T extends BookTargetDoc>(docs: T[], slugMa
48
67
  export declare function partitionByBook<T extends BookTargetDoc>(docs: T[], slugMap?: SlugMap): Map<string, T[]>;
49
68
  /** Named books only, in stable order — the extra volumes beside the default one. */
50
69
  export declare function namedBooks<T extends BookTargetDoc>(docs: T[], slugMap?: SlugMap): string[];
70
+ export declare const bookTextOf: (doc: {
71
+ text: string;
72
+ bookText?: string;
73
+ }) => string;
74
+ export declare const mayHaveSingleSourceDirective: (text: string) => boolean;
51
75
  export {};
@@ -29,6 +29,11 @@ export const NO_BOOK = 'none';
29
29
  * `book: ["default", "field-guide"]` binds the doc into both.
30
30
  */
31
31
  export const DEFAULT_BOOK_NAME = 'default';
32
+ const placementKey = (book) => book === DEFAULT_BOOK ? DEFAULT_BOOK_NAME : book;
33
+ /** Volumes a doc's `placement` names, as book keys. */
34
+ function placedBooks(doc) {
35
+ return Object.keys(doc.placement ?? {}).map((k) => k.trim().toLowerCase() === DEFAULT_BOOK_NAME ? DEFAULT_BOOK : k.trim());
36
+ }
32
37
  /**
33
38
  * The doc itself, then each ancestor nearest-first. Stops at an unresolvable parent and
34
39
  * at a cycle, so a self-parented doc yields just itself rather than hanging.
@@ -86,14 +91,51 @@ function normalizeDeclaration(value) {
86
91
  * another, or bind into several (`["default", "appendices"]`).
87
92
  */
88
93
  export function resolveBooks(doc, docs, slugMap = {}) {
94
+ const placed = placedBooks(doc);
95
+ const withPlaced = (books) => [
96
+ ...books,
97
+ ...placed.filter((b) => !books.includes(b)),
98
+ ];
89
99
  for (const d of chain(doc, docs, slugMap)) {
90
100
  if (d.book === undefined)
91
101
  continue;
92
102
  const declared = normalizeDeclaration(d.book);
93
- if (declared !== null)
94
- return declared;
103
+ /*
104
+ An explicit `none` on the doc ITSELF still wins over its own placement: withholding is
105
+ the conservative reading of a contradiction, as it is for `["default", "none"]`. An
106
+ inherited `none` does not, or a chapter could never be placed into a book from a section
107
+ that is otherwise site-only.
108
+ */
109
+ if (declared !== null) {
110
+ if (!declared.length && d === doc)
111
+ return [];
112
+ return withPlaced(declared);
113
+ }
95
114
  }
96
- return [DEFAULT_BOOK];
115
+ return withPlaced([DEFAULT_BOOK]);
116
+ }
117
+ /**
118
+ * The docs of one volume, arranged for it: each doc's `placement` for this book overlaid on
119
+ * its site `parent` / `order` / `pin` / `title`. Returns shallow copies; the site's
120
+ * arrangement is untouched.
121
+ */
122
+ export function placeInBook(docs, book) {
123
+ const key = placementKey(book);
124
+ return docs.map((doc) => {
125
+ const entry = Object.entries(doc.placement ?? {}).find(([k]) => k.trim() === key ||
126
+ (book === DEFAULT_BOOK && k.trim().toLowerCase() === DEFAULT_BOOK_NAME));
127
+ if (!entry)
128
+ return doc;
129
+ // Only what a placement is documented to set: an entry is author-written JSON, and
130
+ // copying whatever it holds would let `"hidden": false` or `"text"` through.
131
+ const out = { ...doc };
132
+ for (const field of ['parent', 'order', 'pin', 'title']) {
133
+ const value = entry[1][field];
134
+ if (value !== undefined)
135
+ out[field] = value;
136
+ }
137
+ return out;
138
+ });
97
139
  }
98
140
  /**
99
141
  * Hidden here or anywhere above.
@@ -134,3 +176,20 @@ export function namedBooks(docs, slugMap = {}) {
134
176
  .filter((k) => k !== DEFAULT_BOOK)
135
177
  .sort();
136
178
  }
179
+ /*
180
+ The text a book binds for this doc.
181
+
182
+ `bookText` exists only where conditional text made the book differ from the site
183
+ (`site/single-source.ts`). Every book output asks here, so there are exactly two readers to
184
+ get right (the ePub and print) and every other consumer keeps reading `text`.
185
+ */
186
+ export const bookTextOf = (doc) => doc.bookText ?? doc.text;
187
+ /*
188
+ Might this source use an inset or conditional text?
189
+
190
+ Deliberately loose: it also matches a directive that is only being SHOWN in a code fence.
191
+ Its two callers want exactly that bias. The build uses it to skip docs that certainly have
192
+ none before asking the parser, and the example editor uses it to refuse a save, where a
193
+ false positive costs a refusal and a false negative writes an edit into the wrong block.
194
+ */
195
+ export const mayHaveSingleSourceDirective = (text) => /<!--\s*\{[^\n]*"(inset|only|end)"\s*:/.test(text);
@@ -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';
@@ -288,8 +288,11 @@ export class TosiDocSystem extends withAttributes({
288
288
  const menuItems = [];
289
289
  // Print / ePub of the whole corpus, before a separator + the prefs.
290
290
  let projectName = '';
291
+ let bookVolumes;
291
292
  try {
292
- projectName = JSON.parse(this.config || '{}').projectName || '';
293
+ const parsed = JSON.parse(this.config || '{}');
294
+ projectName = parsed.projectName || '';
295
+ bookVolumes = parsed.bookVolumes || undefined;
293
296
  }
294
297
  catch {
295
298
  // ignore — fall through to document.title
@@ -298,12 +301,16 @@ export class TosiDocSystem extends withAttributes({
298
301
  menuItems.push({
299
302
  caption: 'Print as PDF',
300
303
  icon: 'printer',
301
- action: () => {
304
+ action: async () => {
305
+ // Opened synchronously, inside the click, or a popup blocker refuses it.
302
306
  const win = window.open('', '_blank');
303
307
  if (!win) {
304
308
  window.alert('Allow pop-ups to print the documentation as a book.');
305
309
  return;
306
310
  }
311
+ // Checked before the book is written: a cover only if the build wrote one
312
+ // and it loads. See `resolveBookCover`.
313
+ const bookCover = await resolveBookCover(bookVolumes);
307
314
  win.document.open();
308
315
  /*
309
316
  `autoPrint: false` — highlight BEFORE printing (review major M1).
@@ -319,6 +326,7 @@ export class TosiDocSystem extends withAttributes({
319
326
  */
320
327
  win.document.write(buildBookHtml(this.corpus, {
321
328
  title: bookTitle,
329
+ cover: bookCover,
322
330
  autoPrint: false,
323
331
  }));
324
332
  win.document.close();
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { type BookPlacement } from '../book-target.js';
1
2
  export interface Doc {
2
3
  text: string;
3
4
  title: string;
@@ -32,6 +33,18 @@ export interface Doc {
32
33
  * individual chapter can still divert, join several volumes, or opt out.
33
34
  */
34
35
  book?: string | string[];
36
+ /**
37
+ * Where this doc sits in a particular volume, when that differs from the site:
38
+ * `{ "language": { "parent": "for-programmers", "order": 20 } }`. Keyed by `book` name
39
+ * (`"default"` for the main volume); each entry may set `parent`, `order`, `pin` and
40
+ * `title`. Naming a volume here also binds the doc into it. The site nav is unaffected.
41
+ */
42
+ placement?: Record<string, BookPlacement>;
43
+ /**
44
+ * The text a book binds, set by the build only where `{"only": …}` conditional text made
45
+ * it differ from `text` (which is always what the site shows). Never author this.
46
+ */
47
+ bookText?: string;
35
48
  /**
36
49
  * Opt this page out of the reading column.
37
50
  *
@@ -99,6 +99,7 @@ import { truncationWarnings, formatTruncationWarnings, } from './truncated-doc.j
99
99
  import { pinnedSort } from '../nav-tree.js';
100
100
  import { buildSlugMap } from '../routing.js';
101
101
  import { withoutHidden } from '../book-target.js';
102
+ import { assembleCorpus, isSingleSourceDirective } from './single-source.js';
102
103
  /**
103
104
  * Directories a doc site should not publish, excluded by default (tosijs-ui#153).
104
105
  *
@@ -234,7 +235,14 @@ function metadata(content, filePath) {
234
235
  And it must START A LINE. A metadata block is a standalone directive, so an inline mention
235
236
  in a sentence is prose — exactly the rule `/*#` doc blocks already follow.
236
237
  */
237
- const source = scannable.match(/^[ \t]*<!--(\{.*\})-->|^[ \t]*\/\*(\{.*\})\*\//m);
238
+ /*
239
+ The first such block that is not a single-sourcing directive. An inset or an `only` is
240
+ spelled the same way, and a doc that opens with one and has no metadata of its own was
241
+ otherwise published carrying `inset` as a metadata field.
242
+ */
243
+ const source = [
244
+ ...scannable.matchAll(/^[ \t]*<!--(\{.*\})-->|^[ \t]*\/\*(\{.*\})\*\//gm),
245
+ ].find((m) => !isSingleSourceDirective(m[1] || m[2]));
238
246
  let data = {};
239
247
  if (source) {
240
248
  try {
@@ -297,7 +305,9 @@ function hasDocBlock(filePath) {
297
305
  return false; // unreadable is not our business here
298
306
  }
299
307
  }
300
- function findMarkdownFiles(paths, ignore, defaultIgnores = new Set()) {
308
+ function findMarkdownFiles(paths, ignore, defaultIgnores = new Set(),
309
+ /** filled with the real path of every directory actually walked (see `assembleCorpus`) */
310
+ walked) {
301
311
  const markdownFiles = [];
302
312
  const truncationFound = [];
303
313
  const withheld = [];
@@ -366,6 +376,12 @@ function findMarkdownFiles(paths, ignore, defaultIgnores = new Set()) {
366
376
  }
367
377
  return;
368
378
  }
379
+ try {
380
+ walked?.add(fs.realpathSync(dir));
381
+ }
382
+ catch {
383
+ // unreadable: nothing in it is scraped, so nothing in it is a fragment either
384
+ }
369
385
  files.forEach((file) => {
370
386
  // Skip scaffolding / working files (Jekyll-style `_`-prefix): a
371
387
  // `_template.md` or `_drafting-log.md` shouldn't leak into the corpus or
@@ -498,7 +514,8 @@ function findMarkdownFiles(paths, ignore, defaultIgnores = new Set()) {
498
514
  }
499
515
  export function extractDocs(options) {
500
516
  const { paths, ignore = ['node_modules', 'dist', 'build'], output } = options;
501
- const found = findMarkdownFiles(paths, ignore, new Set(DEFAULT_DOC_IGNORES));
517
+ const walked = new Set();
518
+ const found = findMarkdownFiles(paths, ignore, new Set(DEFAULT_DOC_IGNORES), walked);
502
519
  /*
503
520
  Drop hidden docs HERE, before anything else sees them.
504
521
 
@@ -513,6 +530,21 @@ export function extractDocs(options) {
513
530
  rest of the pipeline is concerned. Descendants of a hidden doc go with it.
514
531
  */
515
532
  const docs = withoutHidden(found, buildSlugMap(found));
533
+ /*
534
+ Insets and conditional text are resolved HERE, for the same reason hidden docs are dropped
535
+ here: the corpus is the thing that ships, so anything assembled later is assembled by each
536
+ consumer separately. After the hidden filter, so a withheld doc cannot be published by
537
+ insetting it; a fragment that should not be a page is a `_`-prefixed file instead, and that
538
+ is the only kind of file an inset will read (see `fragmentPath`).
539
+ */
540
+ /*
541
+ A fragment may be read from a directory this extraction WALKED, and nowhere else. Not a
542
+ list of roots with the ignore rule applied again: the re-review found that second copy
543
+ disagreeing with the first within the hour (a file doc path such as `README.md` made the
544
+ whole project a root, and a path-form ignore was not honoured). The walk already decided
545
+ which directories are published, so ask it.
546
+ */
547
+ assembleCorpus(docs, { fragmentDirs: walked });
516
548
  if (output) {
517
549
  saveDocsJSON(docs, output);
518
550
  }
@@ -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;