@heroiclands/package-build 24.0.1 → 24.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/assets/preview/gitea.ini +1 -1
  3. package/assets/styles/content-base.css +33 -0
  4. package/bin/content-commands.mjs +17 -17
  5. package/content-config.mjs +1 -52
  6. package/docs/api.md +20 -13
  7. package/docs/authoring/links-and-markup.md +165 -17
  8. package/docs/commands.md +20 -20
  9. package/docs/configuration.md +29 -69
  10. package/docs/content-format.md +4 -2
  11. package/docs/guides/site.md +3 -1
  12. package/docs/guides/writing-in-invented-scripts.md +68 -6
  13. package/docs/reference/format-details.md +160 -118
  14. package/docs/reference/note-types.md +49 -29
  15. package/docs/source-preview.md +2 -2
  16. package/engine/content-blocks.mjs +6 -2
  17. package/engine/content-charset.mjs +1 -0
  18. package/engine/content-format.yaml +2 -0
  19. package/engine/content-index.mjs +5 -1
  20. package/engine/content-render.mjs +2 -1
  21. package/engine/content-scripts.mjs +268 -21
  22. package/engine/content-scroll.mjs +389 -0
  23. package/engine/dictionary-layout.mjs +16 -3
  24. package/engine/font-outlines.mjs +394 -0
  25. package/engine/foreign-catalog.mjs +11 -122
  26. package/engine/language-data.mjs +154 -63
  27. package/engine/language-rows.mjs +35 -9
  28. package/engine/language-terms.mjs +39 -9
  29. package/engine/map-lexicon.mjs +31 -6
  30. package/engine/note-vocabulary.mjs +58 -6
  31. package/engine/pdf-build.mjs +9 -178
  32. package/engine/pdf-fonts.mjs +1 -1
  33. package/engine/pdf-render.mjs +52 -11
  34. package/engine/run-rows.mjs +45 -3
  35. package/engine/script-direction.mjs +67 -0
  36. package/engine/script-emphasis-terms.mjs +11 -3
  37. package/engine/script-font-delivery.mjs +42 -9
  38. package/engine/script-fonts.mjs +7 -0
  39. package/engine/site-reach.mjs +23 -0
  40. package/engine/site-render.mjs +18 -3
  41. package/engine/verse-metre.mjs +1 -0
  42. package/engine/written-in.mjs +208 -0
  43. package/package.json +2 -1
  44. package/site-theme/static/css/style.css +7 -0
  45. package/types/content-config.d.mts +1 -19
  46. package/types/engine/content-scripts.d.mts +31 -6
  47. package/types/engine/content-scroll.d.mts +179 -0
  48. package/types/engine/dictionary-layout.d.mts +5 -0
  49. package/types/engine/font-outlines.d.mts +67 -0
  50. package/types/engine/foreign-catalog.d.mts +0 -38
  51. package/types/engine/language-data.d.mts +13 -0
  52. package/types/engine/language-rows.d.mts +5 -4
  53. package/types/engine/language-terms.d.mts +7 -0
  54. package/types/engine/map-lexicon.d.mts +4 -3
  55. package/types/engine/pdf-build.d.mts +5 -27
  56. package/types/engine/pdf-fonts.d.mts +10 -0
  57. package/types/engine/run-rows.d.mts +28 -6
  58. package/types/engine/script-direction.d.mts +10 -0
  59. package/types/engine/script-emphasis-terms.d.mts +10 -3
  60. package/types/engine/script-font-delivery.d.mts +5 -3
  61. package/types/engine/script-fonts.d.mts +9 -0
  62. package/types/engine/site-reach.d.mts +11 -0
  63. package/types/engine/written-in.d.mts +76 -0
  64. package/engine/asset-replacement.mjs +0 -93
  65. package/types/engine/asset-replacement.d.mts +0 -22
@@ -124,15 +124,7 @@ import {
124
124
  pdfTextWidth,
125
125
  resamplePdfImages,
126
126
  } from "./pdf-images.mjs";
127
- import {
128
- ASSETS_SEGMENT,
129
- assetPathnameKey,
130
- pathnameProblem,
131
- resolvePathname,
132
- } from "./pathnames.mjs";
133
- import { ASSET_SYSTEM, assetTypeOfRoot, isAssetShortcode } from "./asset-types.mjs";
134
- import { canonicalKey } from "./address.mjs";
135
- import { isComplete as isForeignCacheComplete, newestVersionDir } from "./metadata-index.mjs";
127
+ import { assetPathnameKey, pathnameProblem, resolvePathname } from "./pathnames.mjs";
136
128
  import {
137
129
  createParser,
138
130
  labelFor,
@@ -147,7 +139,6 @@ import { noteInfoboxes } from "./infobox-registry.mjs";
147
139
  import { resolveIconGlyphs } from "./pdf-fonts.mjs";
148
140
  import { drawMapsFrom } from "./derived-sections.mjs";
149
141
  import { generatedDrawing } from "./generated-sections.mjs";
150
- import { resolveAssetReplacement } from "./asset-replacement.mjs";
151
142
  import { typstScriptFontPath } from "./script-font-delivery.mjs";
152
143
 
153
144
  /**
@@ -166,39 +157,18 @@ import { typstScriptFontPath } from "./script-font-delivery.mjs";
166
157
  * a build from touching a path outside its own output.
167
158
  *
168
159
  * A file **this** package ships stages directly. A pathname naming another
169
- * package's file stages instead from a declared replacement's cached archive,
170
- * when `opts.resolveReplacement` — the pure function
171
- * {@link module:engine/asset-replacement.resolveAssetReplacement} —
172
- * answers it; `opts.foreignIndex` is the merged index that answer is read
173
- * against. A pathname naming a package no replacement carries it for, or a
174
- * URL, names something no build here can open — a build reaches no network —
175
- * and the caller reports it as a picture the book will not carry.
160
+ * package's file, or a URL, names something no build here can open — a build
161
+ * reaches no network — and the caller reports it as a picture the book will
162
+ * not carry.
176
163
  *
177
164
  * @param {string} src - The pathname, as authored.
178
165
  * @param {object} config - The resolved configuration.
179
- * @param {object} [opts]
180
- * @param {Map<string, object>} [opts.foreignIndex] - Every fetched
181
- * dependency's own records, keyed by canonical address — the index a
182
- * replacement's record is read out of.
183
- * @param {Function} [opts.resolveReplacement] - {@link
184
- * module:engine/asset-replacement.resolveAssetReplacement}, or an
185
- * equivalent a caller injects for a test. Omitted, a foreign pathname this
186
- * package does not own stays declined exactly as it always has.
187
166
  * @returns {{from: string, to: string}|null} The file, and where under the
188
167
  * output directory it is staged.
189
- * @throws {Error} When a declared replacement answers the address but its
190
- * archive was never fetched.
191
168
  */
192
- export function stagedImagePath(src, config, opts = {}) {
169
+ export function stagedImagePath(src, config) {
193
170
  const forms = resolvePathname(src, config);
194
171
  if (!forms || forms.state !== "package") return null;
195
- // A declared replacement answers before the package's own file does, which
196
- // is the whole of what declaring one buys: the address a note writes is
197
- // this package's own, and the picture staged for it is the replacement's.
198
- // A miss falls through, so a package declaring none stages exactly what it
199
- // ships.
200
- const replaced = opts.resolveReplacement ? stagedReplacementPath(forms, config, opts) : null;
201
- if (replaced) return replaced;
202
172
  if (forms.own) {
203
173
  return {
204
174
  from: path.resolve(config.rootDir, forms.local),
@@ -208,126 +178,6 @@ export function stagedImagePath(src, config, opts = {}) {
208
178
  return null;
209
179
  }
210
180
 
211
- /**
212
- * Every declared relationship that opted a dependency in as this package's
213
- * asset replacement, in declaration order.
214
- *
215
- * The same shape {@link module:engine/foreign-catalog.itemCatalogRelationships}
216
- * returns, read directly rather than through that module: this file stays a
217
- * leaf over the configuration it is handed, and the flag is as small to read
218
- * back as it was to validate.
219
- *
220
- * @param {object} config - The resolved build configuration.
221
- * @returns {Array<{id: string, package: string}>} The relationships, each
222
- * with the package segment its own content index publishes under.
223
- */
224
- function assetReplacementRelationships(config) {
225
- const out = [];
226
- for (const entries of Object.values(config.relationships ?? {})) {
227
- for (const rel of entries ?? []) {
228
- if (rel.assetReplacement) {
229
- out.push({ id: rel.id, package: rel.contentPackage ?? rel.id });
230
- }
231
- }
232
- }
233
- return out;
234
- }
235
-
236
- /**
237
- * The address a pathname this package does not own is read back as, for
238
- * asking whether a declared replacement answers it instead of the package
239
- * the pathname names.
240
- *
241
- * {@link module:engine/asset-replacement.resolveAssetReplacement} only ever
242
- * matches an address whose package segment is the compiling package's own,
243
- * so the pathname's own package is set aside and its suffix — the one piece
244
- * every surface derives from, and the piece a replacement publishes under
245
- * its own package instead — is read back as this package's type and
246
- * shortcode.
247
- *
248
- * @param {import("./pathnames.mjs").PathnameForms} forms - The pathname,
249
- * resolved.
250
- * @param {object} config - The resolved build configuration.
251
- * @returns {string|null} The canonical address, or `null` when the suffix is
252
- * not shaped like an asset's.
253
- */
254
- function localAssetAddress(forms, config) {
255
- const segments = String(forms.suffix ?? "").split("/");
256
- const assetType = assetTypeOfRoot(segments[0]);
257
- if (!assetType) return null;
258
- const base = segments[segments.length - 1];
259
- const ext = path.extname(base);
260
- const shortcode = (ext ? base.slice(0, -ext.length) : base).toLowerCase();
261
- if (!isAssetShortcode(shortcode)) return null;
262
- return canonicalKey(config.contentPackage, ASSET_SYSTEM, assetType.type, shortcode);
263
- }
264
-
265
- /**
266
- * The cache directory one relationship's archive was fetched into, found the
267
- * way {@link module:engine/foreign-catalog.foreignItemCatalogDirs} finds an
268
- * item catalogue's: the newest `.complete`-stamped `<id>@<version>`
269
- * directory under `config.paths.foreignCache`.
270
- *
271
- * @param {object} config - The resolved build configuration.
272
- * @param {string} id - The relationship's dependency id.
273
- * @returns {string|null} The directory, or `null` when nothing is cached.
274
- */
275
- function cachedReplacementDir(config, id) {
276
- const root = config.paths.foreignCache;
277
- if (!fs.existsSync(root)) return null;
278
- const cached = fs
279
- .readdirSync(root)
280
- .filter((name) => name.startsWith(`${id}@`))
281
- .map((name) => path.join(root, name))
282
- .filter(isForeignCacheComplete);
283
- return cached.length ? newestVersionDir(cached) : null;
284
- }
285
-
286
- /**
287
- * The file a declared replacement stages in place of a pathname this package
288
- * does not own, or `null` when no replacement answers it.
289
- *
290
- * @param {import("./pathnames.mjs").PathnameForms} forms - The pathname,
291
- * resolved, with `forms.own` already `false`.
292
- * @param {object} config - The resolved build configuration.
293
- * @param {object} opts
294
- * @param {Map<string, object>} [opts.foreignIndex] - Every fetched
295
- * dependency's own records, keyed by canonical address.
296
- * @param {Function} opts.resolveReplacement - {@link
297
- * module:engine/asset-replacement.resolveAssetReplacement}.
298
- * @returns {{from: string, to: string}|null} The file, staged at the same
299
- * `pdf`-relative path this address would have taken had this package
300
- * shipped it.
301
- * @throws {Error} When the replacement that answers the address has not
302
- * been fetched.
303
- */
304
- function stagedReplacementPath(forms, config, { foreignIndex = new Map(), resolveReplacement }) {
305
- const address = localAssetAddress(forms, config);
306
- if (!address) return null;
307
- const relationships = assetReplacementRelationships(config);
308
- if (!relationships.length) return null;
309
- const hit = resolveReplacement(address, {
310
- localPackage: config.contentPackage,
311
- replacements: relationships.map((rel) => rel.package),
312
- index: foreignIndex,
313
- });
314
- if (!hit) return null;
315
- const assetPath = hit.record?.asset?.path;
316
- if (!assetPath) return null;
317
- const rel = relationships.find((entry) => entry.package === hit.package);
318
- const dir = cachedReplacementDir(config, rel?.id ?? hit.package);
319
- if (!dir) {
320
- throw new Error(
321
- `\`${hit.package}\` declares \`assetReplacement: true\` and carries \`${address}\`, ` +
322
- "but its archive has not been fetched. Run `package-build deps fetch` first.",
323
- );
324
- }
325
- return {
326
- from: path.join(dir, ASSETS_SEGMENT, assetPath),
327
- to: forms.pdf,
328
- };
329
- }
330
-
331
181
  /**
332
182
  * Where the book keeps the pictures its section plates are drawn over.
333
183
  *
@@ -553,10 +403,6 @@ function configFileFor(config, rootDir) {
553
403
  * {@link module:engine/pdf-pages}.
554
404
  * @param {Function} [opts.readPages] - {@link
555
405
  * module:engine/pdf-pages.readEntryPages}, for tests.
556
- * @param {Function} [opts.resolveReplacement] - {@link
557
- * module:engine/asset-replacement.resolveAssetReplacement}, for tests.
558
- * Loaded from that module otherwise, and only when a relationship declares
559
- * `assetReplacement: true` — a build with none never imports it.
560
406
  * @returns {Promise<{built: boolean, reason: string|null, findings: object[],
561
407
  * imageReports: object[], books: Array<{id: string, title: string,
562
408
  * built: boolean, typ: string, pdf: string|null, stats: object}>,
@@ -576,7 +422,6 @@ export async function buildPdf({
576
422
  version = "",
577
423
  compile = true,
578
424
  readPages = readEntryPages,
579
- resolveReplacement,
580
425
  } = {}) {
581
426
  const resolved = config ?? loadPackConfig();
582
427
  const findings = [];
@@ -642,7 +487,7 @@ export async function buildPdf({
642
487
  out || resolved.pdf.out || path.join("build", "dist"),
643
488
  );
644
489
 
645
- const world = await prepareWorld(resolved, { contentBase, findings, resolveReplacement });
490
+ const world = await prepareWorld(resolved, { contentBase, findings });
646
491
  if (!world) return none(null);
647
492
 
648
493
  const books = [];
@@ -944,11 +789,10 @@ function locateType(contentBase, record) {
944
789
  * @param {object} shared - Options.
945
790
  * @param {string} shared.contentBase - The content tree.
946
791
  * @param {object[]} shared.findings - Collected here rather than thrown.
947
- * @param {Function} [shared.resolveReplacement] - As {@link buildPdf}.
948
792
  * @returns {Promise<object|null>} The world, or null when a site gate fired
949
793
  * and the findings say why.
950
794
  */
951
- async function prepareWorld(resolved, { contentBase, findings, resolveReplacement }) {
795
+ async function prepareWorld(resolved, { contentBase, findings }) {
952
796
  // The corpus, once — the same records the index, the site and the SQL
953
797
  // tables read, so the book cannot select a note the other surfaces do not
954
798
  // have.
@@ -1038,17 +882,6 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
1038
882
  const md = createParser(resolved.icons);
1039
883
  const glyphs = resolveIconGlyphs(resolved.icons, resolved.pdf.iconFonts, findings);
1040
884
 
1041
- // Asked only where a relationship declares `assetReplacement: true`: with
1042
- // none declared the resolver is never consulted, and staging gives the
1043
- // answer it gives a package that declares no replacement. A caller may
1044
- // supply its own, which is how the staging tests drive it.
1045
- const stagingOpts = {
1046
- foreignIndex: gates.foreign.index,
1047
- resolveReplacement:
1048
- resolveReplacement ??
1049
- (assetReplacementRelationships(resolved).length ? resolveAssetReplacement : null),
1050
- };
1051
-
1052
885
  return {
1053
886
  records,
1054
887
  db,
@@ -1060,7 +893,6 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
1060
893
  assets,
1061
894
  md,
1062
895
  glyphs,
1063
- stagingOpts,
1064
896
  };
1065
897
  }
1066
898
 
@@ -1150,7 +982,6 @@ async function selectBook(book, index, { world, configFile, configText, findings
1150
982
  * @param {{get: Function}} [shared.elsewhere] - From
1151
983
  * {@link module:engine/pdf-pages.elsewhereFor}: the notes another book is
1152
984
  * home to, which this book names by book and page rather than links.
1153
- * @param {Function} [shared.resolveReplacement] - As {@link buildPdf}.
1154
985
  * @param {object[]} shared.findings - Collected here rather than thrown.
1155
986
  * @returns {Promise<{built: boolean, typ: string|null, pdf: string|null,
1156
987
  * stats: object|null, imageReports?: object[]}>} What was written: the
@@ -1178,10 +1009,10 @@ async function buildBook(book, shared) {
1178
1009
  * @returns {{from: string, to: string}|null} As {@link stagedImagePath}.
1179
1010
  */
1180
1011
  const stage = (src) => {
1181
- const staged = stagedImagePath(src, resolved, world.stagingOpts);
1012
+ const staged = stagedImagePath(src, resolved);
1182
1013
  return staged && { ...staged, to: inBook(staged.to) };
1183
1014
  };
1184
- const { records, site, gates, byFile, sqlTables, assets, md, glyphs, stagingOpts } = world;
1015
+ const { records, site, gates, byFile, sqlTables, assets, md, glyphs } = world;
1185
1016
 
1186
1017
  fs.mkdirSync(outDir, { recursive: true });
1187
1018
 
@@ -55,7 +55,7 @@ import { familyOf } from "./content-icons.mjs";
55
55
  * @param {Buffer} buf - The font file.
56
56
  * @returns {Map<string, {offset: number, length: number}>} Its table directory.
57
57
  */
58
- function tableDirectory(buf) {
58
+ export function tableDirectory(buf) {
59
59
  const tables = new Map();
60
60
  if (buf.length < 12) return tables;
61
61
  // A TrueType collection points at its first font; a bare font starts at 0.
@@ -14,10 +14,11 @@
14
14
  /**
15
15
  * A note's markdown, and a document plan, rendered as Typst source.
16
16
  *
17
- * **This module emits text and reads nothing.** It takes markdown and a plan and
18
- * returns a `.typ` document; the filesystem, the note bodies and the compiler
19
- * that turns the result into a PDF all live in
20
- * {@link module:engine/pdf-build}. That split is what lets the outline, the
17
+ * **This module emits text and reads nothing but a face's outlines.** It takes
18
+ * markdown and a plan and returns a `.typ` document; the filesystem, the note
19
+ * bodies and the compiler that turns the result into a PDF all live in
20
+ * {@link module:engine/pdf-build}. The one file it reads is the TrueType face a
21
+ * `scroll` fence is drawn from, through {@link module:engine/font-outlines}. That split is what lets the outline, the
21
22
  * table of contents, every anchor and every link destination be asserted in a
22
23
  * unit test with no renderer installed — which is most of what a book has to
23
24
  * get right, and all of what a test can check without eyes.
@@ -95,6 +96,8 @@ import {
95
96
  verseStanzas,
96
97
  } from "./content-verse.mjs";
97
98
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
99
+ import { panelRoman, scanScrolls, scrollPanels, scrollParagraphs } from "./content-scroll.mjs";
100
+ import { BOOK_INK, BOOK_RUBRIC, fontOutlines, scrollPanelSvg } from "./font-outlines.mjs";
98
101
  import { HTML_TAG, htmlMessage } from "./content-html.mjs";
99
102
  import { EXPRESSION } from "./markdown-expressions.mjs";
100
103
  import { inlineText } from "./name-markup.mjs";
@@ -498,11 +501,12 @@ export function markdownToTypst(markdown, opts = {}) {
498
501
  // is an offset into *this* string, and the renderer below walks slices of it.
499
502
  if (!opts.nested) reportUnrenderable(authored, definitions, opts, given);
500
503
  // Text marked with a language, a font or a map is rewritten once, ahead of
501
- // every other pass, line for line; the nested calls below read the result.
502
- const source =
503
- opts.nested ? authored : (
504
- renderRuns(authored, opts.runs, undefined, { target: "book" }).markdown
505
- );
504
+ // every other pass, line for line; the nested calls below read the result,
505
+ // and the scroll fences' layouts the rewrite returns.
506
+ const marked =
507
+ opts.nested ? undefined : renderRuns(authored, opts.runs, undefined, { target: "book" });
508
+ const source = marked ? marked.markdown : authored;
509
+ if (marked) sharedOptions.scrolls = marked.scrolls;
506
510
  // One map for the whole body, not one per block: a heading inside a
507
511
  // blockquote or a list item shares the entry's anchor namespace with every
508
512
  // other heading in the same body, because `sectionLabel` scopes by entry
@@ -522,6 +526,7 @@ export function markdownToTypst(markdown, opts = {}) {
522
526
  seen: new Map(),
523
527
  footnoteState,
524
528
  footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
529
+ scrolls: marked?.scrolls ?? opts.scrolls ?? new Map(),
525
530
  };
526
531
  // A withheld section is set in the box a `:::secret` is set in, and its
527
532
  // heading is set inside it: a printed page honours no reader permission, so
@@ -752,6 +757,10 @@ function renderBlock(tokens, i, out, ctx) {
752
757
  }
753
758
  case "fence":
754
759
  case "code_block": {
760
+ if (token.type === "fence" && /^scroll(?:\s|$)/.test(token.info?.trim() || "")) {
761
+ out.push(scrollBlock(token, ctx));
762
+ return 1;
763
+ }
755
764
  if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info?.trim() || "")) {
756
765
  const opening = `${token.markup}${token.info}`;
757
766
  const poem = scanVerse(`${opening}\n${token.content}${token.markup}`).blocks[0];
@@ -1199,6 +1208,38 @@ function padCells(cells, columns) {
1199
1208
  return padded.map((c) => `[${c}]`).join(", ");
1200
1209
  }
1201
1210
 
1211
+ /**
1212
+ * A `scroll` fence as the book sets it: Typst has no vertical writing, so each
1213
+ * panel of at most `rows` × `cols` characters is an image drawn from the
1214
+ * face's outlines, its roman text as alt text. The panels sit in a paragraph
1215
+ * set right to left, so they are read from the right and wrap to the next
1216
+ * line where the measure ends; without `cols`, each column is a panel, and as
1217
+ * many columns sit side by side as fit. A fence the rewrite of marked text did
1218
+ * not set, or whose face has no TrueType file, is set as code.
1219
+ *
1220
+ * @param {object} token - The fence token.
1221
+ * @param {object} ctx - Render context, holding the scroll layouts.
1222
+ * @returns {string} Typst markup.
1223
+ */
1224
+ function scrollBlock(token, ctx) {
1225
+ const scroll = scanScrolls(`${token.markup}${token.info}\n${token.content}${token.markup}`)
1226
+ .blocks[0];
1227
+ const layout = ctx.scrolls?.get(scrollParagraphs(token.content.split("\n")).join("\n\n"));
1228
+ if (!scroll || scroll.invalid || !layout?.face?.file)
1229
+ return rawBlock(token.content, token.info?.trim() || "");
1230
+ const outlines = fontOutlines(layout.face.file);
1231
+ const { rows, panels } = scrollPanels(layout.paragraphs, scroll);
1232
+ const boxes = panels.map(
1233
+ (panel) =>
1234
+ `#box(image(bytes("${escapeTypstString(scrollPanelSvg(panel, rows, outlines))}"), ` +
1235
+ `format: "svg", height: ${rows}em, alt: "${escapeTypstString(panelRoman(panel, layout.paragraphs))}"))`,
1236
+ );
1237
+ // A zero-width space lets a line break between two columns and adds no
1238
+ // width; panels of several columns are kept a column gap apart.
1239
+ const between = scroll.cols === undefined ? "\u200B" : "#h(1em, weak: true) ";
1240
+ return `\n#block(width: 100%)[#set text(dir: rtl)\n${boxes.join(between)}\n]\n\n`;
1241
+ }
1242
+
1202
1243
  /**
1203
1244
  * A fenced block as Typst raw text.
1204
1245
  *
@@ -1944,10 +1985,10 @@ export function bookTypstPreamble(runs = {}, families = {}) {
1944
1985
  // The title sets short of the measure, so a plate's last line does not
1945
1986
  // run to the trimmed edge of the paper.
1946
1987
  "#let book-title-measure = book-text-width - 2.5cm",
1947
- '#let book-ink = rgb("#241f1a")',
1988
+ `#let book-ink = rgb("${BOOK_INK}")`,
1948
1989
  // A rubric script's emphasis: a vermilion that holds 4.5:1 on the paper
1949
1990
  // and stays apart from the ink in lightness.
1950
- '#let book-rubric = rgb("#9c281b")',
1991
+ `#let book-rubric = rgb("${BOOK_RUBRIC}")`,
1951
1992
  '#let book-paper = rgb("#f4efe4")',
1952
1993
  '#let book-accent = rgb("#7c3b1e")',
1953
1994
  '#let book-head = rgb("#5e2b14")',
@@ -17,6 +17,10 @@
17
17
  * column value and none is a key, so two notes writing one tag in different
18
18
  * case are two rows. Documentation records and assets contribute no rows.
19
19
  *
20
+ * A run's `font` and `map` are the ones it is set with: its own, else those of
21
+ * the div or fence around it, else those its language's note names in
22
+ * `data.writtenIn`, filled by {@link fillWrittenIn} once every record exists.
23
+ *
20
24
  * @module
21
25
  */
22
26
 
@@ -24,6 +28,9 @@ import { canonicalKey, readCanonicalKey } from "./address.mjs";
24
28
  import { encodeAddresses } from "./address-values.mjs";
25
29
  import { isNoteRecord } from "./index-records.mjs";
26
30
  import { scanRuns } from "./content-scripts.mjs";
31
+ import { languageRecordsByName } from "./map-lexicon.mjs";
32
+ import { mapsFromRecords } from "./text-maps.mjs";
33
+ import { fromWrittenIn, writtenInOf } from "./written-in.mjs";
27
34
 
28
35
  /** Every column of the `runs` relation, in order. */
29
36
  export const RUN_COLUMNS = Object.freeze([
@@ -41,12 +48,14 @@ export const RUN_COLUMNS = Object.freeze([
41
48
  * One run as an index record carries it.
42
49
  *
43
50
  * @typedef {object} RecordRun
44
- * @property {"span"|"block"|"verse"} kind - The construct.
51
+ * @property {"span"|"block"|"verse"|"scroll"} kind - The construct.
45
52
  * @property {string|null} lang - The language it is set in, as written: its
46
53
  * own `lang=`, else the one the div or fence around it states.
47
54
  * @property {string|null} tag - The BCP 47 tag it resolves to, or null.
48
- * @property {string|null} font - Its font, as written and inherited the same way.
49
- * @property {string|null} map - Its map, as written and inherited the same way.
55
+ * @property {string|null} font - Its font, as written and inherited the same
56
+ * way, else its language's script's.
57
+ * @property {string|null} map - Its map, as written and inherited the same way,
58
+ * else its language's script's.
50
59
  * @property {number} line - 1-based file line of its opening.
51
60
  * @property {number} column - 1-based UTF-16 file column of its opening.
52
61
  */
@@ -80,6 +89,39 @@ export function recordRuns(body, { context, bodyLine = 1, bodyColumn = 1, syntax
80
89
  }));
81
90
  }
82
91
 
92
+ /**
93
+ * Give every recorded run the map and face its language's note names in
94
+ * `data.writtenIn`, where the run leaves them unstated, as a run is set. Read
95
+ * once every record exists, since a run's language note may be recorded after
96
+ * the note that marks it.
97
+ *
98
+ * @param {Record<string, any>[]} records - The package's index records; each
99
+ * note record's `runs` is updated in place.
100
+ * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
101
+ * The configured languages, whose `note` names each language note.
102
+ */
103
+ export function fillWrittenIn(records, config) {
104
+ const maps = mapsFromRecords(records);
105
+ const scripts = writtenInOf(languageRecordsByName(records, config), maps);
106
+ if (!scripts.size) return;
107
+ for (const record of records) {
108
+ if (!record || !isNoteRecord(record) || !Array.isArray(record.runs)) continue;
109
+ for (const run of record.runs) {
110
+ const filled = fromWrittenIn(
111
+ {
112
+ lang: run.lang ?? undefined,
113
+ map: run.map ?? undefined,
114
+ font: run.font ?? undefined,
115
+ },
116
+ scripts,
117
+ maps,
118
+ );
119
+ if (filled.map !== undefined) run.map = filled.map;
120
+ if (filled.font !== undefined) run.font = filled.font;
121
+ }
122
+ }
123
+ }
124
+
83
125
  /**
84
126
  * The containing page's NOTE identity.
85
127
  *
@@ -0,0 +1,67 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **The direction a script is written in.** A script note states it beside its
16
+ * faces:
17
+ *
18
+ * ```yaml
19
+ * data:
20
+ * script:
21
+ * fonts: [teng-chou]
22
+ * emphasis: rubric
23
+ * direction: vertical-rl
24
+ * ```
25
+ *
26
+ * `vertical-rl` is a script written in columns, top to bottom, the columns
27
+ * read from the right. A script that states no direction is written in lines.
28
+ * Markup never states a direction: a `scroll` fence takes its script's, and is
29
+ * refused in a script written in lines; see
30
+ * {@link module:engine/content-scroll}.
31
+ *
32
+ * @module
33
+ */
34
+
35
+ import { isNoteRecord } from "./index-records.mjs";
36
+ import { SCRIPT_DIRECTIONS, SCRIPT_SUBTYPE } from "./script-emphasis-terms.mjs";
37
+
38
+ export { SCRIPT_DIRECTIONS } from "./script-emphasis-terms.mjs";
39
+
40
+ /**
41
+ * Each face's direction, from the script notes among the index records. The
42
+ * first note to list a face keeps it; a face whose note states no direction,
43
+ * or one the build does not know, is absent.
44
+ *
45
+ * @param {Iterable<Record<string, any>>|undefined} records - Index records.
46
+ * @returns {Map<string, "vertical-rl">} The directions, by face slug.
47
+ */
48
+ export function directionsFromRecords(records) {
49
+ const out = new Map();
50
+ const listed = new Set();
51
+ for (const record of records ?? []) {
52
+ if (!record || !isNoteRecord(record)) continue;
53
+ if (
54
+ record.type !== "skill" ||
55
+ String(record.subType ?? "").toLowerCase() !== SCRIPT_SUBTYPE
56
+ )
57
+ continue;
58
+ const script = record.data?.script;
59
+ if (!script || typeof script !== "object" || !Array.isArray(script.fonts)) continue;
60
+ for (const slug of script.fonts) {
61
+ if (typeof slug !== "string" || listed.has(slug)) continue;
62
+ listed.add(slug);
63
+ if (SCRIPT_DIRECTIONS.includes(script.direction)) out.set(slug, script.direction);
64
+ }
65
+ }
66
+ return out;
67
+ }
@@ -12,9 +12,10 @@
12
12
  */
13
13
 
14
14
  /**
15
- * **The words a script note and a language note state emphasis by.** Read by
16
- * the vocabulary as it is declared, so this module imports nothing; the
17
- * checks and the conventions' use are in {@link module:engine/script-emphasis}.
15
+ * **The words a script note and a language note state emphasis and direction
16
+ * by.** Read by the vocabulary as it is declared, so this module imports
17
+ * nothing; the checks and the conventions' use are in
18
+ * {@link module:engine/script-emphasis} and {@link module:engine/script-direction}.
18
19
  *
19
20
  * @module
20
21
  */
@@ -34,5 +35,12 @@ export const EMPHASIS_CLASSES = Object.freeze({
34
35
  */
35
36
  export const LANGUAGE_EMPHASES = Object.freeze(["words", ...EMPHASIS_CONVENTIONS]);
36
37
 
38
+ /**
39
+ * The directions a script states it is written in. `vertical-rl` is columns,
40
+ * top to bottom, read from the right; a script stating none is written in
41
+ * lines.
42
+ */
43
+ export const SCRIPT_DIRECTIONS = Object.freeze(["vertical-rl"]);
44
+
37
45
  /** The `subType` of a script note. */
38
46
  export const SCRIPT_SUBTYPE = "script";
@@ -13,9 +13,9 @@
13
13
 
14
14
  /**
15
15
  * **Delivering the installed faces to each target.** A consumer that installs
16
- * `@heroiclands/fonts` and sets text in one of its faces with `font=` gets the
17
- * fonts on the site, in the Foundry module and in its books; without both,
18
- * nothing ships and nothing links to it.
16
+ * `@heroiclands/fonts` and sets text in one of its faces, with `font=` or by a
17
+ * language note's `data.writtenIn`, gets the fonts on the site, in the Foundry
18
+ * module and in its books; without both, nothing ships and nothing links to it.
19
19
  *
20
20
  * - **Site and Foundry** receive `fonts.css`, listing only the WOFF2 sources,
21
21
  * the WOFF2 files and `LICENSE`.
@@ -27,9 +27,12 @@
27
27
  import fs from "node:fs";
28
28
  import path from "node:path";
29
29
 
30
+ import YAML from "yaml";
31
+
30
32
  import { baseStyleLayer } from "./base-styles.mjs";
31
33
  import { scanRuns } from "./content-scripts.mjs";
32
34
  import { FONTS_MANIFEST, resolveScriptFonts } from "./script-fonts.mjs";
35
+ import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
33
36
 
34
37
  /**
35
38
  * Where the files land in the staged Foundry module, relative to its root.
@@ -65,10 +68,37 @@ const LICENSE = "LICENSE";
65
68
  /** The faces each configuration's notes set text in, found once. */
66
69
  const usedFaces = new WeakMap();
67
70
 
71
+ /** A note's frontmatter block. */
72
+ const FRONTMATTER = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
73
+
74
+ /**
75
+ * The face a language note names in `data.writtenIn`, where it is installed.
76
+ *
77
+ * @param {string} source - The note's file.
78
+ * @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
79
+ * @returns {string|undefined} The face's slug.
80
+ */
81
+ function writtenInFace(source, installed) {
82
+ const yaml = FRONTMATTER.exec(source)?.[1];
83
+ if (yaml === undefined) return undefined;
84
+ let fm;
85
+ try {
86
+ fm = YAML.parse(yaml);
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ if (fm?.type !== "skill" || String(fm.subType ?? "").toLowerCase() !== LANGUAGE_SUBTYPE)
91
+ return undefined;
92
+ const font = fm.data?.writtenIn?.font;
93
+ return typeof font === "string" && installed.faces.has(font) ? font : undefined;
94
+ }
95
+
68
96
  /**
69
- * The faces the package's notes set text in with `font=`, read from the
70
- * notes themselves: every Markdown file under the content tree that writes
71
- * `font=` is scanned for its runs.
97
+ * The faces the package's notes set text in, read from the notes
98
+ * themselves: every Markdown file under the content tree that writes `font=`
99
+ * is scanned for its runs, and the face every language note names in
100
+ * `data.writtenIn` counts, since a run marked with the language alone is set
101
+ * in it.
72
102
  *
73
103
  * @param {DeliveryConfig} config - The resolved configuration.
74
104
  * @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
@@ -90,10 +120,13 @@ export function facesUsed(config, installed) {
90
120
  if (entry.isDirectory()) walk(full);
91
121
  else if (entry.isFile() && entry.name.endsWith(".md")) {
92
122
  const source = fs.readFileSync(full, "utf8");
123
+ if (/^\s*writtenIn:/m.test(source)) {
124
+ const face = writtenInFace(source, installed);
125
+ if (face) used.add(face);
126
+ }
93
127
  if (!/\bfont=/.test(source)) continue;
94
- const body = source.replace(
95
- /^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/,
96
- (frontmatter) => frontmatter.replace(/[^\r\n]/g, " "),
128
+ const body = source.replace(FRONTMATTER, (frontmatter) =>
129
+ frontmatter.replace(/[^\r\n]/g, " "),
97
130
  );
98
131
  const ctx = { languages: config.languages ?? {}, faces: installed.faces };
99
132
  for (const run of scanRuns(body, ctx).runs)