tosijs-ui 1.16.8 → 1.16.9

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,73 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.9
4
+
5
+ Two sets of asks from tjs-lang for *The TJS Programming Language*, a book that teaches
6
+ programming in the page. Single-sourcing for books (#217): one corpus, several outputs,
7
+ nothing kept in sync by hand. And live examples that open with their code showing and a
8
+ console that speaks the example's language (#216). Additive, with one behaviour change to
9
+ know about: in a `tjs` or `ts` example, or one in a registered dialect, a line typed at the
10
+ console is now evaluated in that language and not as JavaScript. `dist/iife.js` grows by
11
+ 0.7 kB gzip.
12
+
13
+ ### The book opens on the project, not on "Adopting the doc system"
14
+
15
+ Both pages are pinned to the top, and the adopting page had an `order` where the README had
16
+ none, so it sorted first: the ePub's opening chapter, and the first entry in the site nav, was
17
+ a page about adopting the doc system. The README now leads both.
18
+
19
+ ### One source, several outputs (#217)
20
+
21
+ - **Per-volume placement.** A page can sit in a different place in each book:
22
+ `{"placement": {"language": {"parent": "for-programmers", "order": 20}}}` in its metadata.
23
+ Each entry may set `parent`, `order`, `pin` and `title`, and naming a volume also binds the
24
+ page into it. The site nav is unaffected.
25
+ - **Text insets.** `<!--{ "inset": "install.md#with-bun" }-->` includes another document, or
26
+ one heading's section of it, in the page. A fragment (a markdown file whose name starts
27
+ with `_`, in a directory the docs are extracted from) can be inset without being a page, and is the only kind of
28
+ file an inset reads. Resolved at extraction, so the site, the ePubs, `llms.txt` and the markdown copies
29
+ all get the assembled text, and an inset passage's live examples and tests run where it is
30
+ inset.
31
+ - **Conditional text.** `<!--{ "only": "book" | "site" }--> … <!--{ "end": "only" }-->`
32
+ conditional text, for wording that belongs to one output, such as "next chapter" against
33
+ "next page". Books read `bookText`, everything else reads `text`; both are in the published
34
+ `docs.json`, so this chooses wording and does not hide anything.
35
+ - **Each is guarded.** An inset that resolves nowhere, an include cycle, a missing heading, a
36
+ file that is not a fragment, an unbalanced `only`, a directive inside a list, quote or
37
+ paragraph, and a placement under a parent that is not in the volume all fail the build.
38
+ - **Print is not a volume.** The in-app Print output takes the book wording but stays the
39
+ whole site in site order; `placement` arranges ePub volumes.
40
+ - New from `tosijs-ui/site`, for a consumer that builds its corpus some other way:
41
+ `assembleCorpus`, `resolveInsets`, `splitConditions`, `SingleSourceError`, `placeInBook`,
42
+ `bookTextOf`, and the `BookPlacement` type.
43
+ - Saving an edited live example back to source is refused on a page whose source uses an
44
+ inset or conditional text, where the example's position on the page no longer identifies a
45
+ block in that file. No existing page is affected: it applies only to a page that uses one
46
+ of the new directives.
47
+
48
+ ### Examples that open with their code showing, and a console in their language (#216)
49
+
50
+ - **A fence picks the view an example opens in.** `{"view": "code"}` in the source block's
51
+ fence options opens the editor beside the preview, in the page and not full-screen;
52
+ `{"view": "console"}` puts the console where the preview would be, beside the code, for
53
+ examples whose point is a value. The editor is built as the example nears the viewport. In
54
+ a narrow column the panes stack. `example.showInline('code' | 'console')` does the same
55
+ from code.
56
+ - **The console evaluates in the example's dialect.** A line goes through the same transform
57
+ as the example's code, with the same fence options, so `x == '5'` in a `tjs` example is
58
+ TJS's `==`. A line the dialect rejects shows the dialect's error and is not retried as
59
+ JavaScript. Plain `js` examples and `run` dialects are unchanged. A registered dialect's
60
+ `transform` now also receives console lines, marked with `options.repl === true`.
61
+ - **Known limit of the above with tjs.** tjs puts setup statements in front of the line it
62
+ translates, so an expression shows its value, but a line that is only a declaration shows a
63
+ leftover value where a browser console shows `undefined`, and a line needing both that
64
+ setup and `await` shows `undefined`. Fixing it needs the setup to be separable, which is
65
+ asked of tjs-lang.
66
+ - The generated JavaScript tjs-lang asked to see already exists as the read-only **JS** tab of
67
+ a `tjs` or `ts` example, and the inline views make it reachable in the page. Showing the
68
+ JavaScript for a single console line is not in this release: a transform's output for one
69
+ line is mostly its runtime prelude, and there is no general way to separate the two.
70
+
3
71
  ## 1.16.8
4
72
 
5
73
  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,6 +3,8 @@ 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,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%; }
@@ -149,7 +150,7 @@ export function buildBookHtml(docs, opts) {
149
150
  const roots = buildNavTree(visible, slugMap);
150
151
  const css = opts.css ?? `${DEFAULT_BOOK_CSS}\n${PRINT_CSS}`;
151
152
  const chapters = flatten(roots)
152
- .map((node) => `<section class="chapter" id="${slugify(node.doc.filename)}">\n${renderDocMarkdown(stripDocMeta(node.doc.text))}\n</section>`)
153
+ .map((node) => `<section class="chapter" id="${slugify(node.doc.filename)}">\n${renderDocMarkdown(stripDocMeta(bookTextOf(node.doc)))}\n</section>`)
153
154
  .join('\n');
154
155
  return `<!DOCTYPE html>
155
156
  <html lang="${opts.lang ?? 'en'}">
@@ -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);
@@ -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
  }
@@ -18,9 +18,9 @@ import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import { renderDocMarkdown } from '../render.js';
20
20
  import { highlightHtml } from '../highlight.js';
21
- import { buildSlugMap, pathForSlug, slugForPath, withBase } from '../routing.js';
21
+ import { buildSlugMap, pathForSlug, resolveParent, slugForPath, withBase, } from '../routing.js';
22
22
  import { buildNavTree } from '../nav-tree.js';
23
- import { partitionByBook, DEFAULT_BOOK } from '../book-target.js';
23
+ import { partitionByBook, placeInBook, bookTextOf, DEFAULT_BOOK, } from '../book-target.js';
24
24
  import { epubVolumeIdentity } from './epub-volumes.js';
25
25
  import { isLiveFence, languageOfClass } from '../example-policy.js';
26
26
  import { DEFAULT_BOOK_CSS, stripDocMeta, flatten, slugify, } from '../book-html.js';
@@ -638,8 +638,28 @@ export async function buildEpub(config, opts = {}) {
638
638
  ` A doc joins a volume with \`{"book": "<name>"}\` (inherited by its children).\n` +
639
639
  ` If this is a typo, note that book names are case- and space-sensitive.`);
640
640
  }
641
+ /*
642
+ Arrange for THIS volume before curating: a doc's `placement` for this book replaces its
643
+ site parent and order (#217), and the manifest then orders what results.
644
+
645
+ A placement parent that is not in the volume is an error. The nav tree would otherwise
646
+ quietly promote the chapter to the top level, which is a book with the wrong outline and a
647
+ green build.
648
+ */
649
+ const book = opts.bookTarget ?? DEFAULT_BOOK;
650
+ const placed = placeInBook(visible, book);
651
+ const placedSlugs = buildSlugMap(placed);
652
+ for (const [i, doc] of placed.entries()) {
653
+ if (doc === visible[i] || !doc.parent)
654
+ continue;
655
+ if (doc.parent === visible[i].parent)
656
+ continue;
657
+ if (!resolveParent(doc.parent, placed, placedSlugs)) {
658
+ throw new Error(`ePub volume ${JSON.stringify(book || '(default)')}: ${doc.filename} is placed under "${doc.parent}", which is not a document in that volume.`);
659
+ }
660
+ }
641
661
  // Curate/reorder for the book (a no-op when config.book is absent).
642
- const docs = selectBookDocs(visible, config.book);
662
+ const docs = selectBookDocs(placed, config.book);
643
663
  const slugMap = buildSlugMap(docs);
644
664
  const roots = buildNavTree(docs, slugMap);
645
665
  const fileFor = (d) => `${slugMap[d.filename] || 'index'}.xhtml`;
@@ -727,7 +747,7 @@ export async function buildEpub(config, opts = {}) {
727
747
  `injectExampleLinks` matches on the `language-*` class, never on the code text, so
728
748
  highlighting cannot break the "Run this example live" links.
729
749
  */
730
- await highlightHtml(renderDocMarkdown(stripDocMeta(doc.text)), 'none'), bookFiles, slugMap, config.basePath);
750
+ await highlightHtml(renderDocMarkdown(stripDocMeta(bookTextOf(doc))), 'none'), bookFiles, slugMap, config.basePath);
731
751
  // happy-dom occasionally throws on exotic content (e.g. an internal selector
732
752
  // bug); fall back to the regex pass for that doc rather than aborting.
733
753
  let bodyHtml;
@@ -18,6 +18,9 @@ export { buildEpub, DEFAULT_BOOK_CSS } from './epub.js';
18
18
  export type { BuildEpubOptions, BookMeta } from './epub.js';
19
19
  export { selectBookDocs } from '../book-manifest.js';
20
20
  export type { BookManifest } from '../book-manifest.js';
21
+ export { placeInBook, bookTextOf } from '../book-target.js';
22
+ export type { BookPlacement } from '../book-target.js';
23
+ export { assembleCorpus, resolveInsets, splitConditions, SingleSourceError, } from './single-source.js';
21
24
  export { listEpubVolumes, epubVolumeIdentity, renderEpubDownloads, type EpubVolume, } from './epub-volumes.js';
22
25
  export { currentHolder, describeHolder, lockPathFor, lockDecision, isProcessAlive, } from './build-lock.js';
23
26
  export type { LockHolder, LockDecision } from './build-lock.js';
@@ -18,6 +18,10 @@ export { extractDocs, saveDocsJSON } from './docs.js';
18
18
  export { generateSite } from './generate-site.js';
19
19
  export { buildEpub, DEFAULT_BOOK_CSS } from './epub.js';
20
20
  export { selectBookDocs } from '../book-manifest.js';
21
+ export { placeInBook, bookTextOf } from '../book-target.js';
22
+ // Insets and conditional text (#217): `extractDocs` applies these; exported for a consumer
23
+ // that builds its corpus some other way.
24
+ export { assembleCorpus, resolveInsets, splitConditions, SingleSourceError, } from './single-source.js';
21
25
  export { listEpubVolumes, epubVolumeIdentity, renderEpubDownloads, } from './epub-volumes.js';
22
26
  /*
23
27
  Reading the build lock is part of the PUBLIC surface, not an internal detail (#117).
@@ -643,6 +643,14 @@ export async function buildSite(config, opts = {}) {
643
643
  doc.text = filled;
644
644
  touched++;
645
645
  }
646
+ // The book's variant is a second copy of the text (conditional text, #217).
647
+ if (typeof doc.bookText === 'string') {
648
+ const book = renderEpubDownloads(doc.bookText, volumes);
649
+ if (book !== doc.bookText) {
650
+ doc.bookText = book;
651
+ touched++;
652
+ }
653
+ }
646
654
  }
647
655
  if (touched)
648
656
  await Bun.write(DOCS_JSON, JSON.stringify(corpus, null, 2));
@@ -0,0 +1,31 @@
1
+ export declare class SingleSourceError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ interface SourceDoc {
5
+ text: string;
6
+ filename: string;
7
+ path?: string;
8
+ title?: string;
9
+ /** the text a book binds, when it differs from `text` (the site's) */
10
+ bookText?: string;
11
+ }
12
+ /** Is this JSON directive single-sourcing rather than doc metadata? */
13
+ export declare function isSingleSourceDirective(json: string): boolean;
14
+ export interface ResolveInsetsOptions {
15
+ /**
16
+ * Real paths of the directories a fragment file may be read from: the ones extraction
17
+ * walked. `extractDocs` supplies this. Without it no file inset is read at all.
18
+ */
19
+ fragmentDirs?: ReadonlySet<string>;
20
+ /** read a fragment file; injectable so the rule is testable without a filesystem */
21
+ readFile?: (absolutePath: string) => string | undefined;
22
+ /** resolve symlinks; injectable for the same reason */
23
+ realPath?: (absolutePath: string) => string;
24
+ }
25
+ export declare function resolveInsets(docs: SourceDoc[], options?: ResolveInsetsOptions): number;
26
+ export declare function splitConditions(text: string, where?: string): {
27
+ site: string;
28
+ book: string;
29
+ };
30
+ export declare function assembleCorpus(docs: SourceDoc[], options?: ResolveInsetsOptions): void;
31
+ export {};