@heroiclands/package-build 23.12.5 → 23.12.7

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 (95) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/assets/styles/content-base.css +25 -0
  3. package/bin/content-commands.mjs +264 -3
  4. package/bin/package-build.mjs +25 -0
  5. package/content-config.mjs +98 -0
  6. package/docs/api.md +18 -14
  7. package/docs/authoring/dates-and-calendars.md +29 -1
  8. package/docs/authoring/links-and-markup.md +442 -50
  9. package/docs/commands.md +127 -0
  10. package/docs/configuration.md +90 -3
  11. package/docs/content-format.md +3 -1
  12. package/docs/guides/book.md +11 -0
  13. package/docs/guides/module-project.md +17 -1
  14. package/docs/guides/site.md +19 -0
  15. package/docs/reference/format-details.md +434 -23
  16. package/docs/reference/note-types.md +45 -21
  17. package/engine/base-compiler.mjs +2 -0
  18. package/engine/bundles.mjs +7 -2
  19. package/engine/compile-corpus.mjs +3 -1
  20. package/engine/content-charset.mjs +21 -2
  21. package/engine/content-format.yaml +1 -2
  22. package/engine/content-index.mjs +33 -2
  23. package/engine/content-links.mjs +3 -0
  24. package/engine/content-poetry.mjs +22 -4
  25. package/engine/content-scripts.mjs +1219 -0
  26. package/engine/content-session.mjs +2 -0
  27. package/engine/data-keys.mjs +5 -1
  28. package/engine/dictionary-layout.mjs +18 -12
  29. package/engine/dictionary-rows.mjs +11 -2
  30. package/engine/event-rows.mjs +63 -18
  31. package/engine/event-views.mjs +28 -15
  32. package/engine/helpers.mjs +8 -2
  33. package/engine/index-records.mjs +2 -0
  34. package/engine/infobox.mjs +4 -8
  35. package/engine/journals.mjs +14 -3
  36. package/engine/language-data.mjs +137 -1
  37. package/engine/language-tag.mjs +103 -0
  38. package/engine/language-terms.mjs +16 -4
  39. package/engine/map-lexicon.mjs +762 -0
  40. package/engine/markdown-expressions.mjs +72 -17
  41. package/engine/markup-validation.mjs +17 -3
  42. package/engine/names.mjs +1 -1
  43. package/engine/note-calendars.mjs +314 -0
  44. package/engine/note-vocabulary.mjs +124 -16
  45. package/engine/pdf-build.mjs +28 -10
  46. package/engine/pdf-render.mjs +116 -11
  47. package/engine/reckoning-markers.mjs +63 -3
  48. package/engine/run-rows.mjs +119 -0
  49. package/engine/run-words.mjs +266 -0
  50. package/engine/scenes.mjs +1 -0
  51. package/engine/script-font-delivery.mjs +321 -0
  52. package/engine/script-fonts.mjs +172 -0
  53. package/engine/site-build.mjs +10 -3
  54. package/engine/site-config.mjs +5 -0
  55. package/engine/site-root.mjs +19 -5
  56. package/engine/sql-tables.mjs +120 -4
  57. package/engine/text-maps.mjs +607 -0
  58. package/engine/verb-pattern.mjs +99 -0
  59. package/engine/word-pattern.mjs +27 -0
  60. package/engine/word-rows.mjs +8 -2
  61. package/engine/word-terms.mjs +44 -0
  62. package/hugo-theme/layouts/_default/baseof.html +3 -0
  63. package/hugo-theme/static/css/style.css +10 -0
  64. package/manifest.mjs +9 -1
  65. package/package.json +1 -1
  66. package/types/content-config.d.mts +38 -0
  67. package/types/engine/compile-corpus.d.mts +2 -1
  68. package/types/engine/content-index.d.mts +17 -3
  69. package/types/engine/content-poetry.d.mts +7 -0
  70. package/types/engine/content-scripts.d.mts +226 -0
  71. package/types/engine/data-keys.d.mts +2 -2
  72. package/types/engine/dictionary-rows.d.mts +15 -0
  73. package/types/engine/event-rows.d.mts +16 -1
  74. package/types/engine/event-views.d.mts +2 -1
  75. package/types/engine/helpers.d.mts +5 -1
  76. package/types/engine/journals.d.mts +7 -2
  77. package/types/engine/language-data.d.mts +2 -0
  78. package/types/engine/language-tag.d.mts +8 -0
  79. package/types/engine/language-terms.d.mts +2 -1
  80. package/types/engine/map-lexicon.d.mts +274 -0
  81. package/types/engine/markup-validation.d.mts +7 -2
  82. package/types/engine/note-calendars.d.mts +60 -0
  83. package/types/engine/pdf-build.d.mts +26 -3
  84. package/types/engine/pdf-render.d.mts +17 -2
  85. package/types/engine/reckoning-markers.d.mts +32 -1
  86. package/types/engine/run-rows.d.mts +89 -0
  87. package/types/engine/run-words.d.mts +86 -0
  88. package/types/engine/script-font-delivery.d.mts +134 -0
  89. package/types/engine/script-fonts.d.mts +168 -0
  90. package/types/engine/site-build.d.mts +2 -1
  91. package/types/engine/site-root.d.mts +11 -3
  92. package/types/engine/text-maps.d.mts +175 -0
  93. package/types/engine/verb-pattern.d.mts +65 -0
  94. package/types/engine/word-pattern.d.mts +13 -0
  95. package/types/engine/word-terms.d.mts +6 -0
@@ -0,0 +1,172 @@
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 faces a consumer installed.** `@heroiclands/fonts` owns the facts
16
+ * about its faces — family, class, direction, features and the code points
17
+ * each one draws — in its `fonts.json`, and this module reads them from the
18
+ * **consuming package's** install.
19
+ *
20
+ * package-build declares no dependency on `@heroiclands/fonts` in any field:
21
+ * the fonts package depends on package-build to build, so the consumer adds it
22
+ * as a devDependency of its own and package-build resolves it from the
23
+ * consumer's root. Resolving from this module's own location would find
24
+ * whatever happens to sit above package-build's install, or nothing.
25
+ *
26
+ * @module
27
+ */
28
+
29
+ import fs from "node:fs";
30
+ import { createRequire } from "node:module";
31
+ import path from "node:path";
32
+
33
+ /** The npm name of the fonts package. */
34
+ export const FONTS_PACKAGE = "@heroiclands/fonts";
35
+
36
+ /** The manifest the fonts package publishes at its root. */
37
+ export const FONTS_MANIFEST = "fonts.json";
38
+
39
+ /**
40
+ * One code point a face draws, as `fonts.json` lists it.
41
+ *
42
+ * @typedef {object} FaceCodepoint
43
+ * @property {string} codepoint - `U+` and the hexadecimal code point.
44
+ * @property {string} glyph - The glyph's name in the face.
45
+ * @property {string} kind - What it is: `letter`, `numeral`, `mark` and so on.
46
+ */
47
+
48
+ /**
49
+ * One entry of `fonts.json`.
50
+ *
51
+ * @typedef {object} FontEntry
52
+ * @property {string} [name] - Its unique key; `family` when absent.
53
+ * @property {string} family - The CSS and Typst font family.
54
+ * @property {string} class - The class that sets text in it, `hl-<slug>`.
55
+ * @property {Record<string, string>} files - Its font files, by format.
56
+ * @property {string} direction - `ltr` or `rtl`.
57
+ * @property {string[]} features - OpenType features it turns on.
58
+ * @property {FaceCodepoint[]} [codepoints] - Every code point it draws.
59
+ */
60
+
61
+ /**
62
+ * A face as a run reads it.
63
+ *
64
+ * @typedef {object} Face
65
+ * @property {string} slug - What `font=` names it by: its class without `hl-`.
66
+ * @property {string} family - The CSS and Typst font family.
67
+ * @property {string} class - The class that sets text in it.
68
+ * @property {string} direction - `ltr` or `rtl`.
69
+ * @property {string[]} features - OpenType features it turns on.
70
+ * @property {ReadonlySet<number>} draws - Every code point it draws.
71
+ */
72
+
73
+ /**
74
+ * The installed fonts package, as a consumer sees it.
75
+ *
76
+ * @typedef {object} ScriptFonts
77
+ * @property {string} root - The package's directory.
78
+ * @property {string} version - Its `package.json` version.
79
+ * @property {{families: FontEntry[]}|undefined} manifest - Its `fonts.json`,
80
+ * `undefined` when the installed version ships none.
81
+ * @property {Map<string, Face>} faces - Each face by its slug, in manifest
82
+ * order; empty without a manifest.
83
+ */
84
+
85
+ /**
86
+ * The slug `font=` names a face by: its class without the `hl-` prefix.
87
+ *
88
+ * @param {FontEntry} entry - A `fonts.json` entry.
89
+ * @returns {string|undefined} The slug, or `undefined` for an entry with no class.
90
+ */
91
+ export function faceSlug(entry) {
92
+ const name = typeof entry?.class === "string" ? entry.class : "";
93
+ return name ? name.replace(/^hl-/, "") : undefined;
94
+ }
95
+
96
+ /**
97
+ * The code point a `U+XXXX` string names.
98
+ *
99
+ * @param {unknown} value - The listed code point.
100
+ * @returns {number|undefined} The code point, or `undefined` when it names none.
101
+ */
102
+ function codepointOf(value) {
103
+ const match = /^U\+([0-9A-Fa-f]{4,6})$/.exec(String(value ?? ""));
104
+ return match ? Number.parseInt(match[1], 16) : undefined;
105
+ }
106
+
107
+ /**
108
+ * The fonts package the consumer at `rootDir` installed.
109
+ *
110
+ * @param {string} rootDir - The consuming package's root, holding its
111
+ * `package.json`.
112
+ * @returns {ScriptFonts|undefined} The package, or `undefined` when the
113
+ * consumer has not installed it.
114
+ * @throws {Error} When the installed `fonts.json` is not readable JSON, or
115
+ * holds no `families` list.
116
+ */
117
+ export function resolveScriptFonts(rootDir) {
118
+ const require = createRequire(path.join(rootDir, "package.json"));
119
+ let packageJson;
120
+ try {
121
+ packageJson = require.resolve(`${FONTS_PACKAGE}/package.json`);
122
+ } catch (err) {
123
+ if (/** @type {{code?: string}} */ (err)?.code === "MODULE_NOT_FOUND") return undefined;
124
+ throw err;
125
+ }
126
+ const root = path.dirname(packageJson);
127
+ const { version } = JSON.parse(fs.readFileSync(packageJson, "utf8"));
128
+
129
+ const manifestFile = path.join(root, FONTS_MANIFEST);
130
+ let manifest;
131
+ if (fs.existsSync(manifestFile)) {
132
+ try {
133
+ manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
134
+ } catch (err) {
135
+ throw new Error(
136
+ `${manifestFile}: error: ${FONTS_PACKAGE} ${version} ships a ` +
137
+ `${FONTS_MANIFEST} that is not readable JSON: ${/** @type {Error} */ (err).message}`,
138
+ { cause: err },
139
+ );
140
+ }
141
+ if (!Array.isArray(manifest?.families)) {
142
+ throw new Error(
143
+ `${manifestFile}: error: ${FONTS_PACKAGE} ${version} ships a ` +
144
+ `${FONTS_MANIFEST} with no \`families\` list`,
145
+ );
146
+ }
147
+ }
148
+
149
+ const faces = new Map();
150
+ for (const entry of manifest?.families ?? []) {
151
+ const slug = faceSlug(entry);
152
+ if (!slug || faces.has(slug)) continue;
153
+ const draws = new Set();
154
+ for (const listed of entry.codepoints ?? []) {
155
+ const point = codepointOf(listed?.codepoint);
156
+ if (point !== undefined) draws.add(point);
157
+ }
158
+ faces.set(
159
+ slug,
160
+ Object.freeze({
161
+ slug,
162
+ family: entry.family,
163
+ class: entry.class,
164
+ direction: entry.direction === "rtl" ? "rtl" : "ltr",
165
+ features: Object.freeze([...(entry.features ?? [])]),
166
+ draws,
167
+ }),
168
+ );
169
+ }
170
+
171
+ return { root, version, manifest, faces };
172
+ }
@@ -65,6 +65,7 @@ import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
65
65
  import { eventNoteIndex } from "./event-fields.mjs";
66
66
  import { expandContentTables } from "./content-tables.mjs";
67
67
  import { renderSpans, scanSpans } from "./content-spans.mjs";
68
+ import { renderRuns, runContext, scanRuns } from "./content-scripts.mjs";
68
69
  import { renderBlocks, renderWithheldSections, scanBlocks } from "./content-blocks.mjs";
69
70
  import { scanHeadingAttributes, withheldSections } from "./heading-attributes.mjs";
70
71
  import { renderFigureBlocks, scanFigures } from "./content-figures.mjs";
@@ -728,6 +729,7 @@ export function renderSitePage(
728
729
  figuresByAddress = new Map(),
729
730
  events,
730
731
  drawings = new Map(),
732
+ runs = runContext(config),
731
733
  },
732
734
  ) {
733
735
  const tableErrors = [];
@@ -879,9 +881,12 @@ export function renderSitePage(
879
881
  const authoredLinks = resolved.length;
880
882
  const resolvedBody =
881
883
  generated ? `${authoredBody}\n${protectCode(generated, resolve)}` : authoredBody;
882
- const figured = renderFigureBlocks(resolvedBody, undefined, undefined, {
883
- resolveRole: (address) => roleByWebSrc.get(address),
884
- });
884
+ const figured = renderFigureBlocks(
885
+ renderRuns(resolvedBody, runs, undefined, { target: "site" }).markdown,
886
+ undefined,
887
+ undefined,
888
+ { resolveRole: (address) => roleByWebSrc.get(address) },
889
+ );
885
890
  for (const error of figureScan.errors)
886
891
  captionErrors.push({
887
892
  file: page.file,
@@ -893,6 +898,7 @@ export function renderSitePage(
893
898
  ...scanBlocks(page.body).errors,
894
899
  ...scanAlerts(page.body).errors,
895
900
  ...scanSpans(page.body).errors,
901
+ ...scanRuns(page.body, runs).errors,
896
902
  ...markupAnchorFindings(page.body),
897
903
  ])
898
904
  secretErrors.push({
@@ -1098,6 +1104,7 @@ export function renderPages(pages, options) {
1098
1104
  figuresByAddress,
1099
1105
  events,
1100
1106
  drawings: maps,
1107
+ runs: runContext(config, records),
1101
1108
  });
1102
1109
  tableErrors.push(...result.tableErrors);
1103
1110
  expressionErrors.push(...result.expressionErrors);
@@ -47,6 +47,7 @@ import { stringify as stringifyToml } from "smol-toml";
47
47
 
48
48
  import { checkHomepage, fail } from "../config.mjs";
49
49
  import { slugify } from "./content-slug.mjs";
50
+ import { siteFontsParam } from "./script-font-delivery.mjs";
50
51
 
51
52
  /** The Hugo source directory, relative to the repository root. */
52
53
  export const HUGO_SOURCE = "build/hugo";
@@ -402,6 +403,10 @@ export function hugoConfig({ config, navigation, themesDir }) {
402
403
  if (config.site.assets) params.cdnBaseURL = config.site.assets;
403
404
  params.brand = { ...BRAND };
404
405
  if (config.site.notfound) params.notfound = structuredClone(config.site.notfound);
406
+ // Named only when the build copies the fonts, so the theme links nothing
407
+ // it does not ship.
408
+ const scriptFonts = siteFontsParam(config);
409
+ if (scriptFonts) params.scriptFonts = scriptFonts;
405
410
 
406
411
  const generated = {
407
412
  baseURL: config.homepage,
@@ -111,16 +111,29 @@ export function noindexHeaders() {
111
111
  }
112
112
 
113
113
  /**
114
- * The `_headers` file's contents: the `noindex` rules, and nothing else.
114
+ * The `_headers` file's contents: the `noindex` rules, and the script fonts'
115
+ * cache rule when the site carries them.
115
116
  *
116
117
  * No `Cache-Control` is pinned on the prefix root. It is the homepage, and a
117
118
  * lifetime on it would hold a stale copy at the most-linked address after a
118
- * deploy; Pages' own defaults for a page apply.
119
+ * deploy; Pages' own defaults for a page apply. The fonts sit under a path
120
+ * that names their version, so a year's lifetime never serves a stale file.
119
121
  *
122
+ * @param {object} [options] - Options.
123
+ * @param {string} [options.pkg] - The content package, which is the prefix.
124
+ * @param {boolean} [options.fonts] - Whether the site carries the fonts.
120
125
  * @returns {string} The file's contents.
121
126
  */
122
- export function headers() {
123
- return noindexHeaders().join("\n");
127
+ export function headers({ pkg, fonts = false } = {}) {
128
+ const lines = noindexHeaders();
129
+ if (fonts && pkg) {
130
+ lines.push(
131
+ `/${pkg}/fonts/heroiclands/*`,
132
+ " Cache-Control: public, max-age=31536000, immutable",
133
+ "",
134
+ );
135
+ }
136
+ return lines.join("\n");
124
137
  }
125
138
 
126
139
  /**
@@ -256,7 +269,8 @@ export async function writeSiteRoot({ pkg, out, search = true, indexer = indexSi
256
269
  }
257
270
 
258
271
  const file = path.join(root, "_headers");
259
- fs.writeFileSync(file, headers());
272
+ const fonts = fs.existsSync(path.join(rendered, "fonts", "heroiclands"));
273
+ fs.writeFileSync(file, headers({ pkg, fonts }));
260
274
  fs.rmSync(path.join(root, "_redirects"), { force: true });
261
275
 
262
276
  const dir = path.join(rendered, SEARCH_DIR);
@@ -31,9 +31,12 @@ import { collectAnchors } from "./anchors.mjs";
31
31
  import { expandContentTables } from "./content-tables.mjs";
32
32
  import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
33
33
  import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs";
34
- import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventRows } from "./event-rows.mjs";
34
+ import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventCalendars, eventRows } from "./event-rows.mjs";
35
35
  import { reckoningContext } from "./reckoning-markers.mjs";
36
36
  import { NAME_COLUMNS, namesRows } from "./name-rows.mjs";
37
+ import { RUN_COLUMNS, ownerOf, runsRows } from "./run-rows.mjs";
38
+ import { MAP_COLUMNS, mapRows } from "./text-maps.mjs";
39
+ import { NATIVE_COLUMNS, nativeRows } from "./map-lexicon.mjs";
37
40
  import {
38
41
  GLOSSARY_COLUMNS,
39
42
  AFFIX_COLUMNS,
@@ -276,6 +279,8 @@ export async function openNotesDatabase(
276
279
  await createRelations(connection, jsonl, encoded, "", audience);
277
280
  await createEventRelation(connection, path.join(base, "events.jsonl"), encoded, "", audience);
278
281
  await createNameRelation(connection, path.join(base, "names.jsonl"), encoded, "", audience);
282
+ await createRunRelation(connection, path.join(base, "runs.jsonl"), encoded, "", audience);
283
+ await createMapRelation(connection, path.join(base, "maps.jsonl"), encoded, "", audience);
279
284
  await createWordRelations(connection, path.join(base, "notes"), encoded, "", audience);
280
285
 
281
286
  // One schema per declared dependency, so `FROM sohl.notes` reads the notes
@@ -312,6 +317,20 @@ export async function openNotesDatabase(
312
317
  `${schema}.`,
313
318
  audience,
314
319
  );
320
+ await createRunRelation(
321
+ connection,
322
+ flat.replace(/\.jsonl$/, "-runs.jsonl"),
323
+ depRecords,
324
+ `${schema}.`,
325
+ audience,
326
+ );
327
+ await createMapRelation(
328
+ connection,
329
+ flat.replace(/\.jsonl$/, "-maps.jsonl"),
330
+ depRecords,
331
+ `${schema}.`,
332
+ audience,
333
+ );
315
334
  await createWordRelations(
316
335
  connection,
317
336
  flat.replace(/\.jsonl$/, ""),
@@ -346,6 +365,8 @@ export async function openNotesDatabase(
346
365
  for (const relation of [
347
366
  "events",
348
367
  "names",
368
+ "runs",
369
+ "maps",
349
370
  "words",
350
371
  "affixes",
351
372
  "fields",
@@ -552,7 +573,7 @@ async function createRelations(connection, file, records, prefix = "", audience
552
573
  */
553
574
  async function createEventRelation(connection, file, records, prefix, audience) {
554
575
  const visible = audience === "public" ? records.filter((record) => !isGmNote(record)) : records;
555
- const rows = eventRows(visible, reckoningContext({ notes: records }));
576
+ const rows = eventRows(visible, reckoningContext({ notes: records }), eventCalendars(records));
556
577
  if (!rows.length) {
557
578
  const columns = EVENT_COLUMNS.map((column) => {
558
579
  const type = EVENT_SORT_COLUMNS.includes(column) ? "DOUBLE" : "VARCHAR";
@@ -564,7 +585,13 @@ async function createEventRelation(connection, file, records, prefix, audience)
564
585
  return;
565
586
  }
566
587
  fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
567
- await createTableFromFile(connection, `${prefix}events`, readJsonAuto(file));
588
+ // `whenIn` and `untilIn` are keyed by calendar Address, and read as structs
589
+ // whatever their number of keys, so `whenIn['<calendar>']` always binds.
590
+ await createTableFromFile(
591
+ connection,
592
+ `${prefix}events`,
593
+ readJsonAuto(file).replace(/\)$/, ", map_inference_threshold=-1)"),
594
+ );
568
595
  }
569
596
 
570
597
  /**
@@ -602,6 +629,95 @@ async function createNameRelation(connection, file, records, prefix, audience) {
602
629
  );
603
630
  }
604
631
 
632
+ /**
633
+ * Create the `runs` relation: one row per span, div and poetry fence an own
634
+ * note marks with `lang=`, `font=` or `map=`, with the columns
635
+ * {@link module:engine/run-rows.RUN_COLUMNS} names. The columns are declared
636
+ * rather than inferred, so every value stays a string and none becomes a
637
+ * struct key whose case DuckDB folds.
638
+ *
639
+ * @param {object} connection - An open DuckDB connection.
640
+ * @param {string} file - Where to write the rows.
641
+ * @param {object[]} records - Index records, every Address a string.
642
+ * @param {string} prefix - A schema to qualify the relation name with.
643
+ * @param {"all"|"public"} audience - Whether to leave out GM notes.
644
+ * @returns {Promise<void>}
645
+ */
646
+ async function createRunRelation(connection, file, records, prefix, audience) {
647
+ const rows = runsRows(
648
+ audience === "public" ? records.filter((record) => !isGmNote(record)) : records,
649
+ );
650
+ await createTypedRelation(connection, file, `${prefix}runs`, rows, RUN_COLUMNS, [
651
+ "line",
652
+ "column",
653
+ ]);
654
+ }
655
+
656
+ /**
657
+ * Create the `maps` relation: one row per row of a mapping note's
658
+ * `data.map`, with the columns {@link module:engine/text-maps.MAP_COLUMNS}
659
+ * names; and the `natives` relation, one row per native form of a language's
660
+ * word, with the columns {@link module:engine/map-lexicon.NATIVE_COLUMNS}
661
+ * names. A row is a row here, never a struct key, so `A` and `a` are two rows.
662
+ *
663
+ * @param {object} connection - An open DuckDB connection.
664
+ * @param {string} file - Where to write the rows.
665
+ * @param {object[]} records - Index records, every Address a string.
666
+ * @param {string} prefix - A schema to qualify the relation name with.
667
+ * @param {"all"|"public"} audience - Whether to leave out GM notes.
668
+ * @returns {Promise<void>}
669
+ */
670
+ async function createMapRelation(connection, file, records, prefix, audience) {
671
+ const rows = mapRows(
672
+ audience === "public" ? records.filter((record) => !isGmNote(record)) : records,
673
+ ownerOf,
674
+ );
675
+ await createTypedRelation(connection, file, `${prefix}maps`, rows, MAP_COLUMNS, ["row"]);
676
+ const visible = audience === "public" ? records.filter((record) => !isGmNote(record)) : records;
677
+ await createTypedRelation(
678
+ connection,
679
+ file.replace(/maps\.jsonl$/, "natives.jsonl"),
680
+ `${prefix}natives`,
681
+ nativeRows(visible),
682
+ NATIVE_COLUMNS,
683
+ ["word", "native"],
684
+ );
685
+ }
686
+
687
+ /**
688
+ * Create a relation whose columns are declared rather than inferred, so every
689
+ * value stays a string or a number and none becomes a struct key whose case
690
+ * DuckDB folds. An empty relation keeps its columns.
691
+ *
692
+ * @param {object} connection - An open DuckDB connection.
693
+ * @param {string} file - Where to write the rows.
694
+ * @param {string} name - The relation, schema-qualified where it is a dependency's.
695
+ * @param {object[]} rows - Its rows.
696
+ * @param {readonly string[]} columns - Its columns, in order.
697
+ * @param {readonly string[]} integers - The columns that are `BIGINT`; the
698
+ * rest are `VARCHAR`.
699
+ * @returns {Promise<void>}
700
+ */
701
+ async function createTypedRelation(connection, file, name, rows, columns, integers) {
702
+ const columnTypes = columns.map((column) => [
703
+ column,
704
+ integers.includes(column) ? "BIGINT" : "VARCHAR",
705
+ ]);
706
+ if (!rows.length) {
707
+ const nulls = columnTypes.map(([column, type]) => `NULL::${type} AS "${column}"`);
708
+ await connection.run(`CREATE VIEW ${name} AS SELECT ${nulls.join(", ")} WHERE false`);
709
+ return;
710
+ }
711
+ fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
712
+ const schema = columnTypes.map(([column, type]) => `'${column}': '${type}'`).join(", ");
713
+ await createTableFromFile(
714
+ connection,
715
+ name,
716
+ `SELECT * FROM read_json('${file.replace(/'/g, "''")}', columns={${schema}}, ` +
717
+ "format='newline_delimited', maximum_object_size=20000000)",
718
+ );
719
+ }
720
+
605
721
  /**
606
722
  * Create a relation as a table, so the JSONL behind it is parsed once when the
607
723
  * database opens rather than on every query. A view over `read_json_auto`
@@ -693,7 +809,7 @@ async function createWordRelations(connection, stem, records, prefix, audience)
693
809
  senses: "STRUCT(text VARCHAR, bodies VARCHAR[])[]",
694
810
  usage: "VARCHAR[]",
695
811
  depth: "BIGINT",
696
- verbs: `STRUCT(invariant BOOLEAN, persons STRUCT(${VERB_PERSON_FIELDS.map((person) => `"${person.name}" VARCHAR`).join(", ")}))`,
812
+ verbs: `STRUCT(invariant BOOLEAN, replaces VARCHAR, persons STRUCT(${VERB_PERSON_FIELDS.map((person) => `"${person.name}" VARCHAR`).join(", ")}))`,
697
813
  };
698
814
  const dictionaryFile = `${stem}-dictionary.jsonl`;
699
815
  if (dictionary.length) {