tosijs-ui 1.6.15 → 1.6.17

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 CHANGED
@@ -1,5 +1,75 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.17
4
+
5
+ ### Fixed
6
+
7
+ - **`@resvg/resvg-js` is now an optional peer dependency** (it was only a dev
8
+ dependency). It rasterizes the generated ePub cover and belongs to the same
9
+ doc-site pipeline as `happy-dom`, which was already an optional peer — so an
10
+ adopter building ePubs via `tosijs-ui/site` got `happy-dom` surfaced by their
11
+ package manager but not `resvg`, and their book's cover silently failed to
12
+ generate. Both are still lazy-loaded with a graceful warning when absent.
13
+
14
+ ## 1.6.16
15
+
16
+ Doc-system: prose/book adoption. Component APIs are unchanged; everything here is
17
+ doc-site tooling (the `tosijs-ui/site` build system + live examples).
18
+
19
+ ### Added
20
+
21
+ - **Book manifest — curate/reorder the book without touching site nav.** A new
22
+ `book` field on the site config (`BookManifest`) selects and sequences the ePub
23
+ (and, later, print) as a subset of the corpus while the live site still shows
24
+ everything — one source, two outputs. `include`/`exclude` globs pick docs,
25
+ `order: [...]` names the lead sequence (front/back matter are just docs you
26
+ name), and `sort: 'filename'` gives a folder of chapters natural order with no
27
+ metadata. It adds no new ordering mechanism — it overlays each doc's `order` so
28
+ the existing nav sort sequences the book (pins/parents still apply). Zero-config
29
+ is unchanged: the book is the whole visible corpus. Identity (title/author/
30
+ cover) still comes from `epub`.
31
+ - **Prose Markdown (Batch A), activated only by its own syntax** so code docs are
32
+ byte-identical: **YAML frontmatter** (`title`/`order`/`author`/`date`/`draft`,
33
+ frontmatter wins over JSON-comment metadata), **wikilinks** `[[slug]]` /
34
+ `[[slug|label]]` → `/slug/`, and **footnotes** `[^id]` → numbered refs + an
35
+ endnotes section (ePub/web).
36
+ - **Book/prose quick wins:** auto-serve `/iife.js` when `bundleEntry` is omitted;
37
+ skip `_`-prefixed scaffolding files; empty metadata `title` falls back to the
38
+ H1; warn when no ePub cover is generated.
39
+ - **`epub.coverIcon`** — embed a custom flat SVG glyph into the generated ePub
40
+ cover (in place of the favicon); the source viewBox is preserved so any
41
+ square-ish icon scales correctly. tosijs-ui ships a `tosi-book` cover glyph.
42
+ - **`<tosi-3d>` declarative attributes** — assemble a scene purely in HTML (no
43
+ `sceneCreated` callback): `src` (a `.glb` URL, auto-loads like `<img>`),
44
+ `hero-light` (a directional key light over a soft hemispheric fill), `fov` (a
45
+ field-of-view multiplier — `1` unchanged, `<1` a longer lens), and `clear-color`
46
+ (a hex color, or `transparent` to composite the 3D over the page). Aimed at
47
+ declarative narrative pages.
48
+
49
+ ### Fixed
50
+
51
+ - **Multiple `<tosi-3d>` on one page.** Their scenes are created asynchronously and
52
+ interleave, so objects created without an explicit scene landed on Babylon's
53
+ `Engine.LastCreatedScene` (another instance's scene) — the built-in default
54
+ camera did this, leaving a scene with no camera and a blank canvas. The default
55
+ camera (and the doc examples' lights) now pass their scene explicitly.
56
+
57
+ ### Changed
58
+
59
+ - **PDF is now the in-browser Print button, not a batch job.** The doc-browser's
60
+ Print action renders the shared book HTML (`book-html.ts`) and the browser
61
+ prints it to PDF. This is the single, supported PDF path going forward; a future
62
+ paginated/footnote-anchored PDF will build on it. (ePub is still generated at
63
+ build time and unaffected.)
64
+ - **Live examples pinned to tjs-lang 0.8.7** (upstream subtle-bug fix).
65
+
66
+ ### Removed
67
+
68
+ - **The headless PDF batch builder** (`buildPdf` / `BuildPdfOptions`, exported
69
+ from `tosijs-ui/site`, and the `book:pdf` / `--pdf` script) — a secondary code
70
+ path superseded by the Print button above. If you generated PDFs in a build,
71
+ print the book page to PDF (via the doc-browser) instead.
72
+
3
73
  ## 1.6.15
4
74
 
5
75
  Live-example fixes from tosijs-3d adoption. All fixes to the doc-system's
@@ -9,6 +9,12 @@ interface B3dUIOptions {
9
9
  type MeshProcessCallback = (meshes: any[]) => void;
10
10
  export declare class B3d extends WebComponent {
11
11
  static preferredTagName: string;
12
+ static initAttributes: {
13
+ src: string;
14
+ clearColor: string;
15
+ fov: number;
16
+ heroLight: boolean;
17
+ };
12
18
  babylonReady: Promise<any>;
13
19
  BABYLON?: any;
14
20
  static shadowStyleSpec: {
@@ -21,7 +21,7 @@ preview.append(b3d({
21
21
  )
22
22
  camera.attachControl(element.parts.canvas, true)
23
23
 
24
- new BABYLON.HemisphericLight('light', new BABYLON.Vector3(0.25, 1, -0.5))
24
+ new BABYLON.HemisphericLight('light', new BABYLON.Vector3(0.25, 1, -0.5), element.scene)
25
25
 
26
26
  this.loadScene('/', 'xin3d.glb')
27
27
 
@@ -129,7 +129,7 @@ preview.append(b3d({
129
129
  const { scene } = element
130
130
  const { createNoise2D } = await import('https://cdn.jsdelivr.net/npm/simplex-noise@4.0.1/+esm')
131
131
 
132
- new BABYLON.HemisphericLight('light', new BABYLON.Vector3(0.25, 1, 2))
132
+ new BABYLON.HemisphericLight('light', new BABYLON.Vector3(0.25, 1, 2), scene)
133
133
 
134
134
  const terrain = new BABYLON.Mesh('terrain', scene)
135
135
  const vertexData = new BABYLON.VertexData()
@@ -168,10 +168,38 @@ preview.append(b3d({
168
168
  }))
169
169
  ```
170
170
 
171
+ ## Pure-HTML scenes (attributes)
172
+
173
+ `<tosi-3d>` can assemble a scene entirely from HTML attributes — no `sceneCreated`
174
+ callback, no JavaScript — so a 3D hero can be dropped into a declarative page. Set
175
+ `src` and it works like an `<img>`; a default camera and lighting are added when
176
+ the scene doesn't provide its own, so a model shows up out of the box.
177
+
178
+ - **`src`** — a `.glb` URL; loads on connect (calls `loadScene` for you).
179
+ - **`hero-light`** — adds a directional key light (front-upper-left) over the soft
180
+ hemispheric fill, for a "product photography" look.
181
+ - **`fov`** — field-of-view *multiplier* on the camera's natural lens: `1` (default)
182
+ leaves it alone, `<1` is a longer lens (less perspective distortion), `>1` wider.
183
+ - **`clear-color`** — scene background: a css hex color, or `transparent` to
184
+ composite the 3D over the page (great for themed narrative pages).
185
+
186
+ This loads the same scene as the example above, in plain HTML, with a hero key
187
+ light and a slightly longer lens:
188
+
189
+ ```html
190
+ <tosi-3d src="/xin3d.glb" hero-light fov="0.6"></tosi-3d>
191
+ ```
192
+ ```css
193
+ .preview tosi-3d {
194
+ width: 100%;
195
+ height: 100%;
196
+ }
197
+ ```
198
+
171
199
  ## loadScene
172
200
 
173
201
  `<tosi-3d>.loadScene(path: string, file: string, callBack(meshes: any[]): void)` can
174
- be used to load `.glb` files.
202
+ be used to load `.glb` files. Setting `src` calls this for you on connect.
175
203
 
176
204
  ## loadUI
177
205
 
@@ -184,8 +212,42 @@ import { icons, svg2DataUrl } from './icons';
184
212
  const noop = () => {
185
213
  /* do not care */
186
214
  };
215
+ /** Parse a css hex color (#rgb/#rrggbb/#rrggbbaa) or the keyword `transparent`
216
+ * into a BABYLON.Color4. */
217
+ function toColor4(BABYLON, css) {
218
+ if (!css || css.trim() === 'transparent')
219
+ return new BABYLON.Color4(0, 0, 0, 0);
220
+ let hex = css.trim().replace(/^#/, '');
221
+ if (hex.length === 3)
222
+ hex = hex
223
+ .split('')
224
+ .map((c) => c + c)
225
+ .join('');
226
+ if (hex.length === 6)
227
+ hex += 'ff';
228
+ const n = parseInt(hex, 16);
229
+ if (Number.isNaN(n) || hex.length !== 8)
230
+ return new BABYLON.Color4(0, 0, 0, 1);
231
+ return new BABYLON.Color4(((n >>> 24) & 255) / 255, ((n >>> 16) & 255) / 255, ((n >>> 8) & 255) / 255, (n & 255) / 255);
232
+ }
187
233
  export class B3d extends WebComponent {
188
234
  static preferredTagName = 'tosi-3d';
235
+ // Declarative config so a scene can be assembled purely in HTML (no JS):
236
+ // <tosi-3d src="/model.glb" hero-light fov="0.5" clear-color="transparent">
237
+ static initAttributes = {
238
+ // `src` (a .glb URL) makes <tosi-3d> work like <img>: the scene loads on connect.
239
+ src: '',
240
+ // scene background: a css hex color, or `transparent` to composite the 3D
241
+ // over the page (e.g. themed narrative pages). Empty = Babylon default.
242
+ clearColor: '',
243
+ // field-of-view MULTIPLIER on the camera's natural lens. 1 = unchanged,
244
+ // <1 = longer lens / less perspective distortion, >1 = wider. Author-friendly:
245
+ // no need to know Babylon's default fov in radians.
246
+ fov: 1,
247
+ // add a directional key light (front-upper-left) for a product-photography
248
+ // look, on top of the soft hemispheric fill.
249
+ heroLight: false,
250
+ };
189
251
  babylonReady;
190
252
  BABYLON;
191
253
  static shadowStyleSpec = {
@@ -281,15 +343,47 @@ export class B3d extends WebComponent {
281
343
  const { canvas } = this.parts;
282
344
  this.babylonReady.then(async (BABYLON) => {
283
345
  this.BABYLON = BABYLON;
284
- this.engine = new BABYLON.Engine(canvas, true);
346
+ // A `transparent` (alpha < 1) clear-color needs an alpha WebGL context so
347
+ // the canvas composites over the page.
348
+ const clearColor = this.clearColor ? toColor4(BABYLON, this.clearColor) : null;
349
+ this.engine = new BABYLON.Engine(canvas, true, clearColor && clearColor.a < 1
350
+ ? { alpha: true, premultipliedAlpha: false }
351
+ : undefined);
285
352
  this.scene = new BABYLON.Scene(this.engine);
353
+ if (clearColor)
354
+ this.scene.clearColor = clearColor;
286
355
  if (this.sceneCreated) {
287
356
  await this.sceneCreated(this, BABYLON);
288
357
  }
358
+ // `src` (pure-HTML usage): auto-load the .glb. Split into the rootUrl +
359
+ // filename that BABYLON.SceneLoader.Append expects.
360
+ if (this.src) {
361
+ const slash = this.src.lastIndexOf('/');
362
+ await this.loadScene(this.src.slice(0, slash + 1) || '/', this.src.slice(slash + 1));
363
+ }
289
364
  if (this.scene.activeCamera === undefined) {
290
- const camera = new BABYLON.ArcRotateCamera('default-camera', -Math.PI / 2, Math.PI / 2.5, 3, new BABYLON.Vector3(0, 0, 0));
365
+ // Pass `this.scene` explicitly: with multiple <tosi-3d> on a page their
366
+ // scenes interleave, so BABYLON.Engine.LastCreatedScene (the implicit
367
+ // default) may point at another instance's scene.
368
+ const camera = new BABYLON.ArcRotateCamera('default-camera', -Math.PI / 2, Math.PI / 2.5, 3, new BABYLON.Vector3(0, 0, 0), this.scene);
291
369
  camera.attachControl(this.parts.canvas, true);
292
370
  }
371
+ // `fov` multiplier: <1 gives a long "product photography" lens (less
372
+ // distortion), >1 a wider one. 1 (default) leaves the camera untouched.
373
+ if (this.fov && this.fov !== 1 && this.scene.activeCamera) {
374
+ this.scene.activeCamera.fov *= this.fov;
375
+ }
376
+ // A model loaded via `src` needs a light or it renders black; add a soft
377
+ // hemispheric fill from above only when the scene has none of its own.
378
+ if (this.scene.lights.length === 0) {
379
+ const hemi = new BABYLON.HemisphericLight('hemi', new BABYLON.Vector3(0, 1, 0), this.scene);
380
+ hemi.intensity = 0.6;
381
+ }
382
+ // `hero-light`: a directional key light (front-upper-left) over the fill.
383
+ if (this.heroLight) {
384
+ const dir = new BABYLON.DirectionalLight('hero', new BABYLON.Vector3(-1, -2, 1), this.scene);
385
+ dir.intensity = 0.8;
386
+ }
293
387
  this.engine.runRenderLoop(this._update);
294
388
  });
295
389
  }
@@ -6,7 +6,7 @@ export interface BookDoc {
6
6
  parent?: string;
7
7
  hidden?: boolean;
8
8
  }
9
- 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/* 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";
9
+ 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/* 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
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";
11
11
  export declare function escapeHtml(s: string): string;
12
12
  export declare function slugify(s: string): string;
@@ -2,9 +2,9 @@
2
2
  Shared, browser-safe assembly of the whole doc corpus into one printable HTML
3
3
  "book": a title page, a generated table of contents, then every doc as a chapter
4
4
  that starts on a new page. Used by the client-side **Print** button in the
5
- doc-browser (the user's browser prints it to PDF) and by the headless PDF builder
6
- (site/pdf.ts). Pure rendering — no fs, no Node-only APIs — so it bundles into the
7
- iife. Live examples are pretty-printed, force-wrapped code listings, not executed.
5
+ doc-browser (the user's browser prints it to PDF). Pure rendering — no fs, no
6
+ Node-only APIs — so it bundles into the iife. Live examples are pretty-printed,
7
+ force-wrapped code listings, not executed.
8
8
  */
9
9
  import { renderDocMarkdown } from './render';
10
10
  import { buildSlugMap } from './routing';
@@ -67,6 +67,13 @@ ol.toc > li > a { font-weight: bold; }
67
67
  /* per-example "run this live" deep link */
68
68
  .example-live-link { margin: 0.6em 0 0; font-size: 0.85em; }
69
69
  .example-live-link a { color: #08c; font-weight: bold; }
70
+ /* footnotes / endnotes + wikilinks */
71
+ sup.footnote-ref { font-size: 0.7em; line-height: 0; }
72
+ sup.footnote-ref a { text-decoration: none; }
73
+ .footnotes { margin-top: 2em; font-size: 0.9em; }
74
+ .footnotes hr { border: none; border-top: 1px solid #ccc; margin: 1em 0; }
75
+ .footnote-backref { text-decoration: none; }
76
+ a.wikilink { text-decoration: none; border-bottom: 1px dotted currentColor; }
70
77
  `;
71
78
  // Print-only layout: page margins, a chapter per page, an avoid-break TOC.
72
79
  export const PRINT_CSS = `
@@ -0,0 +1,36 @@
1
+ export interface BookManifest {
2
+ /**
3
+ * Globs selecting which docs are in the book (matched against each doc's
4
+ * `path` AND `filename`, so `chapters/**` or `*.md` both work). Default: every
5
+ * visible doc.
6
+ */
7
+ include?: string[];
8
+ /** Globs removed from the selection, applied after `include`. */
9
+ exclude?: string[];
10
+ /**
11
+ * Explicit reading order, by filename (with or without extension), slug, or
12
+ * title. Listed docs lead in this exact sequence; everything else follows in
13
+ * normal order. Unmatched keys are ignored.
14
+ */
15
+ order?: string[];
16
+ /**
17
+ * Baseline order for docs you neither pinned via metadata nor named in
18
+ * `order`: `'nav'` (default — today's pin / `order` / title sort) or
19
+ * `'filename'` (natural sort, so `01-*.md`, `02-*.md`, … sequence with zero
20
+ * metadata). A per-doc `order` still wins over `'filename'`.
21
+ */
22
+ sort?: 'nav' | 'filename';
23
+ }
24
+ interface BookDocLike {
25
+ filename: string;
26
+ path?: string;
27
+ title?: string;
28
+ order?: number;
29
+ }
30
+ /**
31
+ * Apply a book manifest to the (already visible) corpus. Returns a NEW array of
32
+ * shallow-copied docs with `order` overlaid for book sequencing. Pure — never
33
+ * mutates the input, so the caller's site-nav copy is untouched.
34
+ */
35
+ export declare function selectBookDocs<T extends BookDocLike>(docs: T[], manifest?: BookManifest): T[];
36
+ export {};
@@ -0,0 +1,96 @@
1
+ /*
2
+ Pure "book manifest" logic: curate a subset of the doc corpus into a book
3
+ (ePub / print) WITHOUT touching the live-site navigation. One source, two
4
+ outputs.
5
+
6
+ Everything here is an overlay on the defaults — omit the manifest and you get the
7
+ whole visible corpus in normal nav order (the doc system's zero-config behavior).
8
+ The manifest never introduces a new ordering mechanism: it selects docs, then
9
+ overlays each doc's `order` so the SHARED buildNavTree/flatten (used by the site,
10
+ print, and ePub alike) sequences the book. So "front/back matter" are just
11
+ regular docs you name in `order` (or position with a per-doc `order`), and pins /
12
+ parents keep working exactly as on the site.
13
+
14
+ No fs, no DOM — a pure function of the docs array, so it's shared + unit-testable.
15
+ */
16
+ // glob → RegExp. `**` matches any run including `/`; `*` matches any run except
17
+ // `/`; `?` matches a single non-`/`. Everything else is matched literally.
18
+ function globToRegExp(glob) {
19
+ let re = '';
20
+ for (let i = 0; i < glob.length; i += 1) {
21
+ const c = glob[i];
22
+ if (c === '*') {
23
+ if (glob[i + 1] === '*') {
24
+ re += '.*';
25
+ i += 1;
26
+ }
27
+ else {
28
+ re += '[^/]*';
29
+ }
30
+ }
31
+ else if (c === '?') {
32
+ re += '[^/]';
33
+ }
34
+ else {
35
+ re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
36
+ }
37
+ }
38
+ return new RegExp('^' + re + '$');
39
+ }
40
+ function matchesAny(doc, globs) {
41
+ return globs.some((re) => re.test(doc.path ?? '') || re.test(doc.filename));
42
+ }
43
+ const baseName = (filename) => filename.replace(/\.[^.]+$/, '');
44
+ /** Does `key` (from an `order` list) name this doc? filename / basename / title. */
45
+ function docMatchesKey(doc, key) {
46
+ const k = key.trim().toLowerCase();
47
+ return (doc.filename.toLowerCase() === k ||
48
+ baseName(doc.filename).toLowerCase() === k ||
49
+ (doc.title ?? '').toLowerCase() === k);
50
+ }
51
+ /**
52
+ * Apply a book manifest to the (already visible) corpus. Returns a NEW array of
53
+ * shallow-copied docs with `order` overlaid for book sequencing. Pure — never
54
+ * mutates the input, so the caller's site-nav copy is untouched.
55
+ */
56
+ export function selectBookDocs(docs, manifest) {
57
+ if (!manifest)
58
+ return docs.slice();
59
+ // 1) select ────────────────────────────────────────────────────────────────
60
+ let selected = docs.slice();
61
+ if (manifest.include && manifest.include.length) {
62
+ const inc = manifest.include.map(globToRegExp);
63
+ selected = selected.filter((d) => matchesAny(d, inc));
64
+ }
65
+ if (manifest.exclude && manifest.exclude.length) {
66
+ const exc = manifest.exclude.map(globToRegExp);
67
+ selected = selected.filter((d) => !matchesAny(d, exc));
68
+ }
69
+ const copies = selected.map((d) => ({ ...d }));
70
+ // 2) baseline order overlay ─────────────────────────────────────────────────
71
+ // 'filename' fills only docs with no explicit `order`, so metadata still wins.
72
+ if (manifest.sort === 'filename') {
73
+ const natural = copies
74
+ .slice()
75
+ .sort((a, b) => (a.path ?? a.filename).localeCompare(b.path ?? b.filename, undefined, {
76
+ numeric: true,
77
+ sensitivity: 'base',
78
+ }));
79
+ natural.forEach((d, i) => {
80
+ if (d.order === undefined)
81
+ d.order = i * 10;
82
+ });
83
+ }
84
+ // 3) explicit `order` list leads, in the given sequence ─────────────────────
85
+ // Large-negative orders keep listed docs ahead of everything else while
86
+ // preserving their relative order; pins/parents still apply (navSortKey).
87
+ if (manifest.order && manifest.order.length) {
88
+ manifest.order.forEach((key, i) => {
89
+ for (const d of copies) {
90
+ if (docMatchesKey(d, key))
91
+ d.order = -1_000_000 + i;
92
+ }
93
+ });
94
+ }
95
+ return copies;
96
+ }
@@ -32,6 +32,109 @@ docMarked.use({
32
32
  },
33
33
  },
34
34
  });
35
+ // ── Prose Markdown: wikilinks + footnotes ────────────────────────────────────
36
+ // Enabled by default because each activates ONLY on its own syntax ([[…]] /
37
+ // [^id]) — a doc that doesn't use them renders byte-identically (tosijs-ui's own
38
+ // docs use neither). marked core supports neither; both are common in prose.
39
+ function slugify(s) {
40
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
41
+ }
42
+ function escapeHtml(s) {
43
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
44
+ }
45
+ // Per-parse footnote state. `parse()` is synchronous and Marked tokenizes fully
46
+ // before rendering, so a module-level object is safe: preprocess resets it, the
47
+ // tokenizers fill it (defs during the block pass, refs during inline — refs set
48
+ // the number by first-appearance order), postprocess renders the endnotes.
49
+ const plainMarked = new Marked(); // renders footnote-definition markdown (no extensions)
50
+ let footnoteDefs = new Map();
51
+ let footnoteOrder = [];
52
+ docMarked.use({
53
+ hooks: {
54
+ preprocess(markdown) {
55
+ footnoteDefs = new Map();
56
+ footnoteOrder = [];
57
+ return markdown;
58
+ },
59
+ postprocess(html) {
60
+ if (footnoteOrder.length === 0)
61
+ return html;
62
+ const items = footnoteOrder
63
+ .map((id) => {
64
+ const body = plainMarked.parseInline(footnoteDefs.get(id) ?? '');
65
+ const eid = slugify(id);
66
+ return `<li id="fn-${eid}"><p>${body} <a href="#fnref-${eid}" class="footnote-backref" aria-label="Back to reference">↩</a></p></li>`;
67
+ })
68
+ .join('\n');
69
+ return `${html}\n<section class="footnotes" role="doc-endnotes">\n<hr />\n<ol>\n${items}\n</ol>\n</section>\n`;
70
+ },
71
+ },
72
+ extensions: [
73
+ {
74
+ // [[slug]] / [[slug|label]] → link to /slug/. Not matched inside code spans
75
+ // (Marked tokenizes those first). Resolves by slugifying the target.
76
+ name: 'wikilink',
77
+ level: 'inline',
78
+ start(src) {
79
+ return src.match(/\[\[/)?.index;
80
+ },
81
+ tokenizer(src) {
82
+ const m = /^\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/.exec(src);
83
+ if (!m)
84
+ return undefined;
85
+ return {
86
+ type: 'wikilink',
87
+ raw: m[0],
88
+ target: m[1].trim(),
89
+ label: (m[2] ?? m[1]).trim(),
90
+ };
91
+ },
92
+ renderer(token) {
93
+ return `<a href="/${slugify(token.target)}/" class="wikilink">${escapeHtml(token.label)}</a>`;
94
+ },
95
+ },
96
+ {
97
+ // [^id]: definition (single line) — collected, rendered as endnotes.
98
+ name: 'footnoteDef',
99
+ level: 'block',
100
+ start(src) {
101
+ return src.match(/^\[\^[^\]]+\]:/m)?.index;
102
+ },
103
+ tokenizer(src) {
104
+ const m = /^\[\^([^\]]+)\]:[ \t]*([^\n]*)(?:\n|$)/.exec(src);
105
+ if (!m)
106
+ return undefined;
107
+ footnoteDefs.set(m[1].trim(), m[2].trim());
108
+ return { type: 'footnoteDef', raw: m[0] }; // collected; renders nothing inline
109
+ },
110
+ renderer() {
111
+ return '';
112
+ },
113
+ },
114
+ {
115
+ // [^id] reference → superscript link to the endnote.
116
+ name: 'footnoteRef',
117
+ level: 'inline',
118
+ start(src) {
119
+ return src.match(/\[\^/)?.index;
120
+ },
121
+ tokenizer(src) {
122
+ const m = /^\[\^([^\]]+)\]/.exec(src);
123
+ if (!m)
124
+ return undefined;
125
+ const id = m[1].trim();
126
+ if (!footnoteOrder.includes(id))
127
+ footnoteOrder.push(id);
128
+ return { type: 'footnoteRef', raw: m[0], id };
129
+ },
130
+ renderer(token) {
131
+ const n = footnoteOrder.indexOf(token.id) + 1;
132
+ const eid = slugify(token.id);
133
+ return `<sup class="footnote-ref"><a href="#fn-${eid}" id="fnref-${eid}">${n}</a></sup>`;
134
+ },
135
+ },
136
+ ],
137
+ });
35
138
  /** Render a doc's markdown text to HTML (synchronous, default marked options). */
36
139
  export function renderDocMarkdown(text) {
37
140
  return docMarked.parse(text);
@@ -14,11 +14,27 @@ export interface Doc {
14
14
  keywords?: string | string[];
15
15
  image?: string;
16
16
  noindex?: boolean;
17
+ author?: string;
18
+ date?: string;
17
19
  }
18
20
  export interface ExtractDocsOptions {
19
21
  paths: string[];
20
22
  ignore?: string[];
21
23
  output?: string;
22
24
  }
25
+ /**
26
+ * Parse & strip a leading YAML frontmatter block (`---\n…\n---`). Every prose
27
+ * toolchain (Jekyll/Hugo/Astro/Obsidian/Pandoc) uses it, so authors paste it in;
28
+ * without this the `---` was rendered as content (and became the doc title).
29
+ *
30
+ * A minimal, dependency-free subset: `key: value` lines mapped onto doc metadata
31
+ * (`title`, `order`→number, `author`, `date`, `draft: true`→hidden). Only strips
32
+ * when the block actually parses as ≥1 key/value pair, so a genuine leading `---`
33
+ * horizontal rule is left alone. Frontmatter wins over the JSON-comment metadata.
34
+ */
35
+ export declare function parseFrontmatter(content: string): {
36
+ data: Partial<Doc>;
37
+ body: string;
38
+ };
23
39
  export declare function extractDocs(options: ExtractDocsOptions): Doc[];
24
40
  export declare function saveDocsJSON(docs: Doc[], outputPath: string): void;
@@ -97,6 +97,55 @@ import * as fs from 'fs';
97
97
  import * as path from 'path';
98
98
  import { pinnedSort } from '../nav-tree';
99
99
  const TRIM_REGEX = /^#+ |`/g;
100
+ /**
101
+ * Parse & strip a leading YAML frontmatter block (`---\n…\n---`). Every prose
102
+ * toolchain (Jekyll/Hugo/Astro/Obsidian/Pandoc) uses it, so authors paste it in;
103
+ * without this the `---` was rendered as content (and became the doc title).
104
+ *
105
+ * A minimal, dependency-free subset: `key: value` lines mapped onto doc metadata
106
+ * (`title`, `order`→number, `author`, `date`, `draft: true`→hidden). Only strips
107
+ * when the block actually parses as ≥1 key/value pair, so a genuine leading `---`
108
+ * horizontal rule is left alone. Frontmatter wins over the JSON-comment metadata.
109
+ */
110
+ export function parseFrontmatter(content) {
111
+ const m = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*/);
112
+ if (!m)
113
+ return { data: {}, body: content };
114
+ const data = {};
115
+ let matched = false;
116
+ for (const line of m[1].split(/\r?\n/)) {
117
+ const kv = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/);
118
+ if (!kv)
119
+ continue;
120
+ matched = true;
121
+ const key = kv[1].toLowerCase();
122
+ const val = kv[2].trim().replace(/^["']|["']$/g, '');
123
+ if (key === 'title') {
124
+ if (val)
125
+ data.title = val; // empty title falls back to the H1
126
+ }
127
+ else if (key === 'order') {
128
+ const n = Number(val);
129
+ if (!Number.isNaN(n))
130
+ data.order = n;
131
+ }
132
+ else if (key === 'author') {
133
+ if (val)
134
+ data.author = val;
135
+ }
136
+ else if (key === 'date') {
137
+ if (val)
138
+ data.date = val;
139
+ }
140
+ else if (key === 'draft') {
141
+ if (/^(true|yes|1)$/i.test(val))
142
+ data.hidden = true; // drafts don't ship
143
+ }
144
+ }
145
+ if (!matched)
146
+ return { data: {}, body: content }; // a bare `---`, not frontmatter
147
+ return { data, body: content.slice(m[0].length).replace(/^\r?\n+/, '') };
148
+ }
100
149
  function metadata(content, filePath) {
101
150
  // Ignore metadata-style comments INSIDE /*# ... */ doc blocks — those are
102
151
  // documentation examples, not real directives. Only line-starting blocks count
@@ -112,6 +161,10 @@ function metadata(content, filePath) {
112
161
  console.error('bad metadata in doc', filePath);
113
162
  }
114
163
  }
164
+ // An empty/blank `title` must not override the H1 (it produced blank nav
165
+ // entries) — drop it so the H1-derived title wins.
166
+ if (typeof data.title === 'string' && data.title.trim() === '')
167
+ delete data.title;
115
168
  return data;
116
169
  }
117
170
  function findMarkdownFiles(paths, ignore) {
@@ -129,6 +182,11 @@ function findMarkdownFiles(paths, ignore) {
129
182
  return;
130
183
  }
131
184
  files.forEach((file) => {
185
+ // Skip scaffolding / working files (Jekyll-style `_`-prefix): a
186
+ // `_template.md` or `_drafting-log.md` shouldn't leak into the corpus or
187
+ // the book.
188
+ if (file.startsWith('_'))
189
+ return;
132
190
  const filePath = path.join(dir, file);
133
191
  let stats;
134
192
  try {
@@ -141,13 +199,14 @@ function findMarkdownFiles(paths, ignore) {
141
199
  traverseDirectory(filePath, ignore);
142
200
  }
143
201
  else if (path.extname(file) === '.md') {
144
- const content = fs.readFileSync(filePath, 'utf8');
202
+ const { data: fm, body: content } = parseFrontmatter(fs.readFileSync(filePath, 'utf8'));
145
203
  markdownFiles.push({
146
204
  text: content,
147
205
  title: content.split('\n')[0].replace(TRIM_REGEX, ''),
148
206
  filename: file,
149
207
  path: filePath,
150
208
  ...metadata(content, filePath),
209
+ ...fm, // frontmatter wins over JSON-comment metadata + the H1
151
210
  });
152
211
  }
153
212
  else if (['.ts', '.js', '.css'].includes(path.extname(file))) {
@@ -180,13 +239,14 @@ function findMarkdownFiles(paths, ignore) {
180
239
  else if (stats.isFile()) {
181
240
  const file = path.basename(dir);
182
241
  if (path.extname(file) === '.md') {
183
- const content = fs.readFileSync(dir, 'utf8');
242
+ const { data: fm, body: content } = parseFrontmatter(fs.readFileSync(dir, 'utf8'));
184
243
  markdownFiles.push({
185
244
  text: content,
186
245
  title: content.split('\n')[0].replace(TRIM_REGEX, ''),
187
246
  filename: file,
188
247
  path: dir,
189
248
  ...metadata(content, dir),
249
+ ...fm,
190
250
  });
191
251
  }
192
252
  }