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.
- package/CHANGELOG.md +88 -0
- package/README.md +1 -1
- package/dist/doc-system/book-html.d.ts +9 -1
- package/dist/doc-system/book-html.js +7 -1
- package/dist/doc-system/book-target.d.ts +24 -0
- package/dist/doc-system/book-target.js +62 -3
- package/dist/doc-system/doc-system.js +11 -3
- package/dist/doc-system/print-window.d.ts +28 -0
- package/dist/doc-system/print-window.js +45 -0
- package/dist/doc-system/site/docs.d.ts +13 -0
- package/dist/doc-system/site/docs.js +35 -3
- 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 +38 -11
- package/dist/doc-system/site/generate-site.d.ts +5 -0
- package/dist/doc-system/site/generate-site.js +1 -0
- package/dist/doc-system/site/index.d.ts +4 -1
- package/dist/doc-system/site/index.js +5 -1
- package/dist/doc-system/site/orchestrator.js +17 -2
- package/dist/doc-system/site/single-source.d.ts +31 -0
- package/dist/doc-system/site/single-source.js +377 -0
- package/dist/doc-system/site/site-config.d.ts +5 -0
- package/dist/iife.js +94 -90
- package/dist/iife.js.map +11 -10
- package/dist/live-example/component.d.ts +26 -0
- package/dist/live-example/component.js +195 -6
- package/dist/live-example/dialects.d.ts +4 -0
- package/dist/live-example/styles.d.ts +17 -0
- package/dist/live-example/styles.js +28 -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,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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
120
122
|
}
|
|
123
|
+
const escapeAttr = (s) => escapeHtml(s).replace(/"/g, '"');
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|