@heroiclands/package-build 24.0.0 → 24.0.2

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 (61) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/assets/preview/gitea.ini +1 -1
  3. package/assets/styles/content-base.css +33 -0
  4. package/bin/content-commands.mjs +9 -7
  5. package/docs/api.md +6 -5
  6. package/docs/authoring/links-and-markup.md +164 -16
  7. package/docs/commands.md +23 -19
  8. package/docs/configuration.md +16 -0
  9. package/docs/content-format.md +4 -2
  10. package/docs/guides/site.md +43 -0
  11. package/docs/guides/writing-in-invented-scripts.md +68 -6
  12. package/docs/reference/format-details.md +143 -49
  13. package/docs/reference/note-types.md +47 -28
  14. package/docs/source-preview.md +2 -2
  15. package/engine/content-blocks.mjs +6 -2
  16. package/engine/content-format.yaml +2 -0
  17. package/engine/content-index.mjs +5 -1
  18. package/engine/content-render.mjs +2 -1
  19. package/engine/content-scripts.mjs +268 -21
  20. package/engine/content-scroll.mjs +389 -0
  21. package/engine/font-outlines.mjs +394 -0
  22. package/engine/language-data.mjs +154 -63
  23. package/engine/language-rows.mjs +32 -6
  24. package/engine/language-terms.mjs +32 -8
  25. package/engine/map-lexicon.mjs +31 -6
  26. package/engine/note-vocabulary.mjs +58 -6
  27. package/engine/pdf-fonts.mjs +1 -1
  28. package/engine/pdf-render.mjs +52 -11
  29. package/engine/run-rows.mjs +45 -3
  30. package/engine/script-direction.mjs +67 -0
  31. package/engine/script-emphasis-terms.mjs +11 -3
  32. package/engine/script-font-delivery.mjs +42 -9
  33. package/engine/script-fonts.mjs +7 -0
  34. package/engine/site-build.mjs +8 -1
  35. package/engine/site-reach.mjs +213 -0
  36. package/engine/site-render.mjs +120 -45
  37. package/engine/site-root.mjs +68 -26
  38. package/engine/site-serve.mjs +2 -1
  39. package/engine/site-theme.mjs +19 -6
  40. package/engine/sql-tables.mjs +9 -6
  41. package/engine/verse-metre.mjs +1 -0
  42. package/engine/written-in.mjs +208 -0
  43. package/package.json +1 -1
  44. package/site-theme/static/css/style.css +7 -0
  45. package/types/engine/content-scripts.d.mts +31 -6
  46. package/types/engine/content-scroll.d.mts +179 -0
  47. package/types/engine/font-outlines.d.mts +67 -0
  48. package/types/engine/language-data.d.mts +13 -0
  49. package/types/engine/language-rows.d.mts +4 -3
  50. package/types/engine/language-terms.d.mts +7 -0
  51. package/types/engine/map-lexicon.d.mts +4 -3
  52. package/types/engine/pdf-fonts.d.mts +10 -0
  53. package/types/engine/run-rows.d.mts +28 -6
  54. package/types/engine/script-direction.d.mts +10 -0
  55. package/types/engine/script-emphasis-terms.d.mts +10 -3
  56. package/types/engine/script-font-delivery.d.mts +5 -3
  57. package/types/engine/script-fonts.d.mts +9 -0
  58. package/types/engine/site-reach.d.mts +76 -0
  59. package/types/engine/site-render.d.mts +4 -2
  60. package/types/engine/site-root.d.mts +2 -0
  61. package/types/engine/written-in.d.mts +76 -0
@@ -51,7 +51,8 @@
51
51
  import fs from "node:fs";
52
52
  import path from "node:path";
53
53
  import * as pagefind from "pagefind";
54
- import { gunzipSync } from "node:zlib";
54
+
55
+ import { feedPaths, reachablePages } from "./site-reach.mjs";
55
56
 
56
57
  import { formatDiagnostic } from "./diagnostics.mjs";
57
58
 
@@ -149,11 +150,40 @@ export function headers({ pkg, base, fonts = false } = {}) {
149
150
  * binary.
150
151
  *
151
152
  * @callback SiteIndexer
152
- * @param {{site: string, output: string}} where - The rendered site, and the
153
- * directory inside it the index is written to.
153
+ * @param {{site: string, output: string, base?: string}} where - The rendered
154
+ * site, the directory inside it the index is written to, and the path the
155
+ * site is served at. With a `base`, only the pages reachable from the
156
+ * homepage are indexed.
154
157
  * @returns {Promise<{pages: number}>} How many pages were indexed.
155
158
  */
156
159
 
160
+ /**
161
+ * The addresses of the pages reachable from a rendered site's homepage.
162
+ *
163
+ * @param {string} site - The rendered site's directory, served at `base`.
164
+ * @param {string} base - The path the site is served at.
165
+ * @returns {Set<string>} Page addresses, each beginning with `base`.
166
+ */
167
+ function reachableFromDisk(site, base) {
168
+ const file = (url) => path.join(site, ...url.slice(base.length).split("/").filter(Boolean));
169
+ const read = (target) => {
170
+ try {
171
+ return fs.readFileSync(target, "utf8");
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ };
176
+ return reachablePages({
177
+ base,
178
+ html: (url) => read(path.join(file(url), "index.html")),
179
+ expand: (target) => {
180
+ if (!target.endsWith(".xml")) return [];
181
+ const xml = read(file(target));
182
+ return xml === undefined ? [] : feedPaths(xml);
183
+ },
184
+ });
185
+ }
186
+
157
187
  /**
158
188
  * Build the search index of a rendered site with Pagefind.
159
189
  *
@@ -169,12 +199,17 @@ export function headers({ pkg, base, fonts = false } = {}) {
169
199
  * - The binary missing is reported by the package as an install failure of
170
200
  * its own; it is rethrown naming the platform package to install.
171
201
  *
202
+ * With a `base`, the index covers the pages reachable from the homepage by
203
+ * the links the rendered HTML carries, and no others: an unlinked page is
204
+ * served but not searchable. Each reachable page is handed to Pagefind as its
205
+ * own file, so a page outside the set is never read.
206
+ *
172
207
  * The output directory is emptied first, so an index of an earlier build's
173
208
  * pages never survives beside the current one.
174
209
  *
175
210
  * @type {SiteIndexer}
176
211
  */
177
- export async function indexSite({ site, output }) {
212
+ export async function indexSite({ site, output, base }) {
178
213
  if (!fs.statSync(site, { throwIfNoEntry: false })?.isDirectory()) {
179
214
  throw new Error(`${site} is not a directory, so there is nothing to index`);
180
215
  }
@@ -201,33 +236,40 @@ export async function indexSite({ site, output }) {
201
236
  }
202
237
  let pages;
203
238
  try {
204
- const added = await index.addDirectory({ path: site });
205
- if (added.errors.length) throw new Error(added.errors.join("; "));
206
- pages = added.page_count;
239
+ if (base) {
240
+ pages = 0;
241
+ for (const url of reachableFromDisk(site, base)) {
242
+ const sourcePath = path.posix.join(
243
+ ...url.slice(base.length).split("/"),
244
+ "index.html",
245
+ );
246
+ const content = fs.readFileSync(path.join(site, sourcePath), "utf8");
247
+ const added = await index.addHTMLFile({ sourcePath, content });
248
+ if (added.errors.length) throw new Error(added.errors.join("; "));
249
+ pages += 1;
250
+ }
251
+ } else {
252
+ const added = await index.addDirectory({ path: site });
253
+ if (added.errors.length) throw new Error(added.errors.join("; "));
254
+ pages = added.page_count;
255
+ }
207
256
  if (!pages) {
208
257
  throw new Error(`no page under ${site} was indexed — the site holds no HTML`);
209
258
  }
210
- const written = await index.writeFiles({ outputPath: output });
211
- if (written.errors.length) throw new Error(written.errors.join("; "));
259
+ // The files are taken from the index in memory and written here,
260
+ // because `writeFiles` can answer while the binary is still flushing
261
+ // them, which leaves an index that a reader loads half-written.
262
+ const { files, errors } = await index.getFiles();
263
+ if (errors.length) throw new Error(errors.join("; "));
264
+ if (!files.length) throw new Error("the search index holds no files");
265
+ for (const file of files) {
266
+ const target = path.join(output, file.path);
267
+ fs.mkdirSync(path.dirname(target), { recursive: true });
268
+ fs.writeFileSync(target, file.content);
269
+ }
212
270
  } finally {
213
271
  await pagefind.close();
214
272
  }
215
- // Pagefind may finish its API call while fragment writes are still visible
216
- // as incomplete gzip streams under heavy parallel load. A successful site
217
- // build must hand deployment complete fragments.
218
- const fragmentDir = path.join(output, "fragment");
219
- const deadline = Date.now() + 5000;
220
- for (;;) {
221
- try {
222
- const fragments = fs.readdirSync(fragmentDir);
223
- if (!fragments.length) throw new Error("no search fragments were written");
224
- for (const name of fragments) gunzipSync(fs.readFileSync(path.join(fragmentDir, name)));
225
- break;
226
- } catch (error) {
227
- if (Date.now() >= deadline) throw error;
228
- await new Promise((resolve) => setTimeout(resolve, 25));
229
- }
230
- }
231
273
  return { pages };
232
274
  }
233
275
 
@@ -289,7 +331,7 @@ export async function writeSiteRoot({ pkg, base, out, search = true, indexer = i
289
331
  }
290
332
  let pages;
291
333
  try {
292
- ({ pages } = await indexer({ site: rendered, output: dir }));
334
+ ({ pages } = await indexer({ site: rendered, output: dir, base: prefix }));
293
335
  } catch (err) {
294
336
  const message = err instanceof Error ? err.message : String(err);
295
337
  // A directory, so the locator is the file field alone: there is no
@@ -45,6 +45,7 @@ import path from "node:path";
45
45
  import { siteBuildFailed } from "./site-build.mjs";
46
46
  import { SITE_THEME_DIR } from "./site-theme.mjs";
47
47
  import { SITE_STATIC_DIR, buildPackageSite, writeSite, writeThemeFiles } from "./site-render.mjs";
48
+ import { siteBase } from "../content-config.mjs";
48
49
  import { SEARCH_DIR, indexSite } from "./site-root.mjs";
49
50
 
50
51
  /** The address a served page listens on for the server's reloads. */
@@ -165,7 +166,7 @@ export async function serveSite({
165
166
 
166
167
  const indexForSearch = async () => {
167
168
  if (!search || config.site.search === false || !state) return;
168
- await indexSite({ site: out, output: path.join(out, SEARCH_DIR) });
169
+ await indexSite({ site: out, output: path.join(out, SEARCH_DIR), base: siteBase(config) });
169
170
  };
170
171
 
171
172
  await buildAll();
@@ -37,7 +37,9 @@
37
37
  * `draftNotice`, `scriptFonts`), its `menu`, and `pages`, every regular page
38
38
  * as `{url, title, section, type, subType, date, content}`. `tagPages` says the site
39
39
  * writes a page for each tag, and `pageExists` answers whether a path inside
40
- * the site is published. `feed` is the site's Atom feed as `{title, url,
40
+ * the site is published. `visible`, where the site sets it, answers whether a
41
+ * generated link may point at a URL; the Related card, the previous and next
42
+ * links and the infobox links leave out, or set as plain text, what it refuses. `feed` is the site's Atom feed as `{title, url,
41
43
  * section}`, `section` being the one its posts are listed in, and
42
44
  * `mergeIndexes` the search bundles of other sites its search reads beside
43
45
  * its own.
@@ -453,11 +455,14 @@ export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report
453
455
  return { bannerUrl: url, title, tagline };
454
456
  };
455
457
 
456
- /** One infobox entry. */
458
+ /** Whether a generated link may point at a URL: any, where the site sets no `visible`. */
459
+ const visible = (url) => (typeof site.visible === "function" ? site.visible(url) : true);
460
+
461
+ /** One infobox entry; one that leads outside the reachable pages is its text alone. */
457
462
  const entryOf = (entry) => ({
458
463
  lead: text(entry?.lead),
459
464
  text: text(entry?.text),
460
- url: entry?.url ? safeUrl(entry.url) : "",
465
+ url: entry?.url && visible(entry.url) ? safeUrl(entry.url) : "",
461
466
  });
462
467
 
463
468
  /** The value of one row, by its kind. */
@@ -552,7 +557,7 @@ export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report
552
557
  const deliberate = [];
553
558
  const merged = [];
554
559
  for (const ref of related.seeAlso ?? []) {
555
- if (seen.has(ref.url)) continue;
560
+ if (seen.has(ref.url) || !visible(ref.url)) continue;
556
561
  seen.add(ref.url);
557
562
  deliberate.push({
558
563
  url: safeUrl(ref.url),
@@ -561,7 +566,7 @@ export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report
561
566
  });
562
567
  }
563
568
  for (const ref of [...(related.mentions ?? []), ...(related.backlinks ?? [])]) {
564
- if (seen.has(ref.url)) continue;
569
+ if (seen.has(ref.url) || !visible(ref.url)) continue;
565
570
  seen.add(ref.url);
566
571
  merged.push(ref);
567
572
  }
@@ -595,7 +600,15 @@ export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report
595
600
 
596
601
  /** The prev/next links of a page. */
597
602
  const navOf = (page, narrow) => {
598
- const { prev, next } = neighbours(page, narrow);
603
+ const { prev, next } = neighbours(
604
+ {
605
+ ...page,
606
+ siblings: (page.siblings ?? []).filter(
607
+ (item) => item.url === page.url || visible(item.url),
608
+ ),
609
+ },
610
+ narrow,
611
+ );
599
612
  if (!prev && !next) return undefined;
600
613
  const link = (item) => item && { url: safeUrl(item.url), title: text(item.title) };
601
614
  return { prev: link(prev), next: link(next) };
@@ -603,11 +603,7 @@ async function createEventRelation(connection, file, records, prefix, audience)
603
603
  fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
604
604
  // `whenIn` and `untilIn` are keyed by calendar Address, and read as structs
605
605
  // whatever their number of keys, so `whenIn['<calendar>']` always binds.
606
- await createTableFromFile(
607
- connection,
608
- `${prefix}events`,
609
- readJsonAuto(file).replace(/\)$/, ", map_inference_threshold=-1)"),
610
- );
606
+ await createTableFromFile(connection, `${prefix}events`, readJsonAuto(file));
611
607
  }
612
608
 
613
609
  /**
@@ -941,13 +937,20 @@ async function createRowRelation(connection, file, rows, view, columns, types) {
941
937
  * type's fields with `NULL` where a record lacks one. A dependency's index has
942
938
  * exactly the same shape and needs exactly the same reading.
943
939
  *
940
+ * `map_inference_threshold=-1` keeps every object a struct. Left at its default,
941
+ * DuckDB reads an object whose keys are sparsely spread across rows (the mean
942
+ * share of rows carrying a key falls under `field_appearance_threshold`) as a
943
+ * map, so `data.<key>` changes meaning with the corpus: one more field on one
944
+ * note can turn a plain comparison into a JSON conversion error.
945
+ *
944
946
  * @param {string} file - The JSONL to read.
945
947
  * @returns {string} The `SELECT … FROM read_json_auto(…)` clause.
946
948
  */
947
949
  function readJsonAuto(file) {
948
950
  return (
949
951
  `SELECT * FROM read_json_auto('${file.replace(/'/g, "''")}', ` +
950
- `format='newline_delimited', union_by_name=true, maximum_object_size=20000000)`
952
+ `format='newline_delimited', union_by_name=true, maximum_object_size=20000000, ` +
953
+ `map_inference_threshold=-1)`
951
954
  );
952
955
  }
953
956
 
@@ -129,6 +129,7 @@ export function stressIndex(word, rule, pairs) {
129
129
  const count = nuclei.length;
130
130
  if (count <= 1) return 0;
131
131
  if (count === 2) return rule.two === "last" ? 1 : 0;
132
+ if (rule.longer === "first") return 0;
132
133
  if (rule.longer === "antepenultimate") return count - 3;
133
134
  if (rule.longer === "final") return count - 1;
134
135
  return count - 2;
@@ -0,0 +1,208 @@
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 script a language is written in.** A language note — a `skill` of
16
+ * `subType: language` — names the map and the face its text is written in:
17
+ *
18
+ * ```yaml
19
+ * data:
20
+ * writtenIn: { map: semaron, font: semaron-kuriale }
21
+ * ```
22
+ *
23
+ * A run marked with the language's name takes them where it states neither:
24
+ * `[Zaron tholmeza]{lang=vyl}` is mapped by `semaron` and set in
25
+ * `semaron-kuriale`. A run that states one of the two takes the other from the
26
+ * default only where they agree, so the run is never set in a face its map is
27
+ * not drawn in:
28
+ *
29
+ * - **`map=` alone** takes the default `font` where that map is drawn in it.
30
+ * - **`font=` alone** takes the default `map` where the map is drawn in that
31
+ * face.
32
+ * - **Both** take nothing.
33
+ *
34
+ * The default belongs to the run's own language, after a div or fence around
35
+ * it has given it its keys: a span marked `lang=en` inside a div of a language
36
+ * with a default takes no face from it.
37
+ *
38
+ * @module
39
+ */
40
+
41
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
42
+ import { nearest } from "./near-miss.mjs";
43
+ import { compileMap } from "./text-maps.mjs";
44
+ import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
45
+
46
+ /** The `data` key a language note names its script in. */
47
+ export const WRITTEN_IN_KEY = "writtenIn";
48
+
49
+ /**
50
+ * A language's script.
51
+ *
52
+ * @typedef {object} WrittenIn
53
+ * @property {string} map - The `data.map.name` of the mapping note.
54
+ * @property {string} font - The slug of the face, one the map is drawn in.
55
+ */
56
+
57
+ /** Whether a value is a plain map. */
58
+ const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
59
+
60
+ /**
61
+ * Whether a map is drawn in a face: it lists the face, or lists none.
62
+ *
63
+ * @param {import("./text-maps.mjs").TextMap} map - The map.
64
+ * @param {string} font - The face's slug.
65
+ * @returns {boolean} Whether the map may set text in it.
66
+ */
67
+ export function drawnIn(map, font) {
68
+ return !map.fonts.size || map.fonts.has(font);
69
+ }
70
+
71
+ /** A list of names as prose: `a`, `a` and `b`, `a`, `b` and `c`. */
72
+ function said(names) {
73
+ const quoted = names.map((name) => `\`${name}\``);
74
+ return quoted.length < 2 ?
75
+ quoted.join("")
76
+ : `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
77
+ }
78
+
79
+ /**
80
+ * The script each configured language's note names, where the map is known
81
+ * and is drawn in the face. A default that names an unknown map or a face its
82
+ * map is not drawn in is the note's finding, and fills no run.
83
+ *
84
+ * @param {ReadonlyMap<string, Record<string, any>>} byName - Each configured
85
+ * language's note record, by language name, from
86
+ * {@link module:engine/map-lexicon.languageRecordsByName}.
87
+ * @param {ReadonlyMap<string, import("./text-maps.mjs").TextMap>} maps - The
88
+ * maps by name.
89
+ * @returns {Map<string, WrittenIn>} The scripts, by language name.
90
+ */
91
+ export function writtenInOf(byName, maps) {
92
+ const out = new Map();
93
+ for (const [name, record] of byName ?? []) {
94
+ const value = record?.data?.[WRITTEN_IN_KEY];
95
+ if (!isMap(value) || typeof value.map !== "string" || typeof value.font !== "string")
96
+ continue;
97
+ const map = maps?.get(value.map);
98
+ if (!map || !drawnIn(map, value.font)) continue;
99
+ out.set(name, Object.freeze({ map: value.map, font: value.font }));
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * The keys a run takes from its language's script: each of `map` and `font`
106
+ * the run does not state, where it agrees with the one the run does.
107
+ *
108
+ * @param {{lang?: string, map?: string, font?: string}} keys - The run's keys,
109
+ * its own and those it inherits.
110
+ * @param {ReadonlyMap<string, WrittenIn>|undefined} scripts - Each language's
111
+ * script, from {@link writtenInOf}.
112
+ * @param {ReadonlyMap<string, import("./text-maps.mjs").TextMap>|undefined} maps -
113
+ * The maps by name.
114
+ * @returns {{map?: string, font?: string}} What the run takes; empty when it
115
+ * takes nothing.
116
+ */
117
+ export function fromWrittenIn(keys, scripts, maps) {
118
+ if (keys.lang === undefined || (keys.map !== undefined && keys.font !== undefined)) return {};
119
+ const script = scripts?.get(keys.lang);
120
+ if (!script) return {};
121
+ if (keys.map === undefined && keys.font === undefined) return { ...script };
122
+ if (keys.map === undefined) {
123
+ const map = maps?.get(script.map);
124
+ return map && drawnIn(map, /** @type {string} */ (keys.font)) ? { map: script.map } : {};
125
+ }
126
+ const map = maps?.get(keys.map);
127
+ return map && drawnIn(map, script.font) ? { font: script.font } : {};
128
+ }
129
+
130
+ /** The maps each lint index's notes declare, read once. */
131
+ const declaredMaps = new WeakMap();
132
+
133
+ /**
134
+ * Each map the index's notes declare, by name; the first note to declare a
135
+ * name keeps it.
136
+ *
137
+ * @param {{notes?: Iterable<{fm?: Record<string, any>}>}} index - The lint index.
138
+ * @returns {Map<string, import("./text-maps.mjs").TextMap>} The maps.
139
+ */
140
+ function mapsIn(index) {
141
+ const known = declaredMaps.get(index);
142
+ if (known) return known;
143
+ const maps = new Map();
144
+ for (const note of index.notes ?? []) {
145
+ const map = compileMap(note?.fm?.data?.map);
146
+ if (map && !maps.has(map.name)) maps.set(map.name, map);
147
+ }
148
+ declaredMaps.set(index, maps);
149
+ return maps;
150
+ }
151
+
152
+ /**
153
+ * Check a note's `data.writtenIn`: that the note is a language, that `map`
154
+ * names a map a mapping note declares, and that the map is drawn in `font`.
155
+ * The data-key check owns the keys and their kinds.
156
+ *
157
+ * @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
158
+ * @param {{index?: {notes?: Iterable<object>}}} [context] - The lint index;
159
+ * without one, only the note's `subType` is checked.
160
+ * @returns {object[]} The findings, each at its key or value.
161
+ */
162
+ export function checkWrittenIn(note, { index } = {}) {
163
+ const value = note?.fm?.data?.[WRITTEN_IN_KEY];
164
+ if (value === undefined || value === null) return [];
165
+ const raw = note.raw ?? "";
166
+ /**
167
+ * @param {Array<string|number>} keyPath - Where the fault is.
168
+ * @param {string} message - What is wrong.
169
+ * @param {boolean} [key] - Locate the key rather than its value.
170
+ */
171
+ const finding = (keyPath, message, key = false) => ({
172
+ file: note.file,
173
+ ...positionOfFrontmatterPath(raw, ["data", WRITTEN_IN_KEY, ...keyPath], { key }),
174
+ severity: "error",
175
+ message,
176
+ });
177
+ if (String(note.fm?.subType ?? "").toLowerCase() !== LANGUAGE_SUBTYPE)
178
+ return [
179
+ finding(
180
+ [],
181
+ `\`data.${WRITTEN_IN_KEY}\` names the script a language is written in, and this ` +
182
+ `note's \`subType\` is not \`${LANGUAGE_SUBTYPE}\``,
183
+ true,
184
+ ),
185
+ ];
186
+ if (!index || !isMap(value) || typeof value.map !== "string") return [];
187
+ const maps = mapsIn(index);
188
+ const map = maps.get(value.map);
189
+ if (!map) {
190
+ const near = nearest(value.map, [...maps.keys()]);
191
+ return [
192
+ finding(
193
+ ["map"],
194
+ `\`data.${WRITTEN_IN_KEY}.map\` is \`${value.map}\`, which no mapping note's ` +
195
+ "`data.map.name` declares" +
196
+ (near ? `; did you mean \`${near}\`?` : ""),
197
+ ),
198
+ ];
199
+ }
200
+ if (typeof value.font !== "string" || drawnIn(map, value.font)) return [];
201
+ return [
202
+ finding(
203
+ ["font"],
204
+ `\`data.${WRITTEN_IN_KEY}.font\` is \`${value.font}\`, which the map \`${map.name}\` ` +
205
+ `is not drawn in; it is drawn in ${said([...map.fonts.keys()])}`,
206
+ ),
207
+ ];
208
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "24.0.0",
3
+ "version": "24.0.2",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -3114,6 +3114,13 @@ summary.info-sidebar-title::after {
3114
3114
  and `mono` set the site's own serif and mono, and the reader's sans. */
3115
3115
  .script-block { margin:1rem 0; line-height:1.7; }
3116
3116
  .script-block[dir="rtl"], .verse[dir="rtl"] { text-align:start; }
3117
+ /* A scroll: columns top to bottom, read from the right; the line height is the
3118
+ column pitch, one em and half an em between columns. A panel wider than the
3119
+ page scrolls sideways from the right. */
3120
+ .single-content .scroll { writing-mode:vertical-rl; line-height:1.5; inline-size:max-content; max-block-size:100%; overflow-x:auto; overflow-y:hidden; margin:1rem 0; }
3121
+ .single-content .scroll[data-rows] { inline-size:calc(var(--scroll-rows) * 1em); }
3122
+ .single-content .scroll[data-cols] { block-size:calc(var(--scroll-cols) * 1.5em); }
3123
+ .single-content .scroll p { margin:0; text-indent:0; }
3117
3124
  .font-serif { font-family:var(--font-body); }
3118
3125
  .font-sans { font-family:sans-serif; }
3119
3126
  .font-mono { font-family:var(--font-mono); }
@@ -63,6 +63,10 @@ export function scanRuns(source: string, ctx?: RunContext, syntax?: import("./co
63
63
  * - **A div or fence in a right-to-left face** carries `dir=rtl`. No direction
64
64
  * mark, `<bdo>` or `<bdi>` is ever written: the code points carry their own
65
65
  * direction.
66
+ * - **A scroll fence's body** is rewritten to one line per paragraph, its
67
+ * lines joined by what the map writes for a space, and left unescaped,
68
+ * since the body is raw. Its layout, each character with the source text it
69
+ * was written from, is returned for the book, which draws it.
66
70
  *
67
71
  * A run with a finding is left as written; {@link scanRuns} reports it.
68
72
  *
@@ -72,8 +76,9 @@ export function scanRuns(source: string, ctx?: RunContext, syntax?: import("./co
72
76
  * scanner projections.
73
77
  * @param {{target?: "site"|"foundry"|"book"}} [options] - The target the
74
78
  * Markdown is rendered for; only `site` writes the search marker.
75
- * @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>}}
76
- * The rewritten Markdown, and the findings of {@link scanRuns}.
79
+ * @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>, scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>}}
80
+ * The rewritten Markdown, the findings of {@link scanRuns}, and each
81
+ * rewritten scroll fence's layout, by its rewritten body.
77
82
  */
78
83
  export function renderRuns(source: string, ctx?: RunContext, syntax?: import("./content-session.mjs").ContentSyntax, options?: {
79
84
  target?: "site" | "foundry" | "book";
@@ -84,6 +89,7 @@ export function renderRuns(source: string, ctx?: RunContext, syntax?: import("./
84
89
  column: number;
85
90
  message: string;
86
91
  }>;
92
+ scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>;
87
93
  };
88
94
  /** The keys this module reads, which no construct passes through as written. */
89
95
  export const RUN_KEYS: readonly string[];
@@ -184,6 +190,11 @@ export type RunDetail = {
184
190
  * - The emphasis it holds, by absolute offset.
185
191
  */
186
192
  emphasis: Delimiter[];
193
+ /**
194
+ * - A scroll fence whose opening has a finding,
195
+ * which is left as written.
196
+ */
197
+ unrolled?: boolean | undefined;
187
198
  };
188
199
  /**
189
200
  * What a run is checked against.
@@ -208,6 +219,12 @@ export type RunContext = {
208
219
  * {@link module :engine/text-maps.mapsFromRecords}.
209
220
  */
210
221
  maps?: ReadonlyMap<string, import("./text-maps.mjs").TextMap> | undefined;
222
+ /**
223
+ * -
224
+ * The script each configured language's note names, by language name, from
225
+ * {@link module :engine/written-in.writtenInOf}.
226
+ */
227
+ writtenIn?: ReadonlyMap<string, import("./written-in.mjs").WrittenIn> | undefined;
211
228
  /**
212
229
  * -
213
230
  * Each configured language's lexicon by name, from
@@ -243,17 +260,25 @@ export type RunContext = {
243
260
  * from {@link module :engine/script-emphasis.languageEmphasesOf}.
244
261
  */
245
262
  languageEmphases?: ReadonlyMap<string, import("./script-emphasis.mjs").LanguageEmphasis> | undefined;
263
+ /**
264
+ * - Each face whose
265
+ * script is written in columns, from the script notes, by
266
+ * {@link module :engine/script-direction.directionsFromRecords}.
267
+ */
268
+ directions?: ReadonlyMap<string, "vertical-rl"> | undefined;
246
269
  };
247
270
  /**
248
- * One span, div or verse fence carrying `lang=`, `font=` or `map=`. Its
249
- * `lang`, `font` and `map` are the ones it is set with: its own, else the
250
- * innermost div or fence around it that states one.
271
+ * One span, div, verse fence or scroll fence carrying `lang=`, `font=` or
272
+ * `map=`. Its `lang`, `font` and `map` are the ones it is set with: its own,
273
+ * else the innermost div or fence around it that states one, else its
274
+ * language's script. A scroll fence that still names no face takes the face of
275
+ * its map where the map is drawn in one.
251
276
  */
252
277
  export type Run = {
253
278
  /**
254
279
  * - Which construct.
255
280
  */
256
- kind: "span" | "block" | "verse";
281
+ kind: "span" | "block" | "verse" | "scroll";
257
282
  /**
258
283
  * - The language as written.
259
284
  */