@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.12.7
4
+
5
+ ### Patch Changes
6
+
7
+ **Writing in the setting's languages and scripts**
8
+
9
+ - Text can be marked with its language and set in one of the HeroicLands fonts, or in plain serif, sans or monospace, on the site, in Foundry and in books.
10
+ - Roman spelling converts into a script through a mapping note, which takes a word from the language's dictionary when the dictionary spells it differently.
11
+ - `package-build transliterate` and `package-build lexicon` show a passage or a word exactly as a build sets it.
12
+ - Converted text is checked against its language, and an unknown word or unregistered name is reported where it is written.
13
+ - A language's present endings can replace its infinitive ending, as Vylari's do.
14
+
15
+ ## 23.12.6
16
+
17
+ ### Patch Changes
18
+
19
+ A package can name a default calendar with `defaultCalendar` in its configuration, and it replaces the world note's calendar as the one dates fall back to.
20
+
21
+ A note's dates print in its own `data.calendar`, which any note may now set, else its culture's, else for a place its parents', else the default. A place whose parents keep different calendars is a build error until it names its own.
22
+
23
+ Chronologies and the other event sections print every date in the calendar of the page showing them, and `dateformat` with no calendar named uses the note's.
24
+
3
25
  ## 23.12.5
4
26
 
5
27
  ### Patch Changes
@@ -164,6 +164,31 @@
164
164
  --poetry-indent: 10em;
165
165
  }
166
166
 
167
+ /* Text set in one of the faces. The font family and features come from the
168
+ * face's own class; a right-to-left block or verse reads from the right. */
169
+ .script-block {
170
+ margin: 1rem 0;
171
+ line-height: 1.7;
172
+ }
173
+
174
+ .script-block[dir="rtl"],
175
+ .poetry[dir="rtl"] {
176
+ text-align: start;
177
+ }
178
+
179
+ /* Text set in the reader's generic families, `font=serif`, `sans` and `mono`. */
180
+ .font-serif {
181
+ font-family: serif;
182
+ }
183
+
184
+ .font-sans {
185
+ font-family: sans-serif;
186
+ }
187
+
188
+ .font-mono {
189
+ font-family: monospace;
190
+ }
191
+
167
192
  /*
168
193
  * Historical info/warn section classes remain available for styled prose.
169
194
  * New alerts use the separate alert classes below.
@@ -30,6 +30,8 @@
30
30
  * npx package-build site [--out <dir>]
31
31
  * npx package-build map [--tree] [--from <shortcode>] [--chart <shortcode>] [--travel] [--out <dir>]
32
32
  * npx package-build reachability <dir> [file] [--index <shortcode>]
33
+ * npx package-build transliterate <map> [--lang <language>] [--font <slug>] [--hex] [text..]
34
+ * npx package-build lexicon <language> --map <map> [--font <slug>] [--json]
33
35
  * npx package-build addresses diff --from <zip|dir> [--strict]
34
36
  * npx package-build npc new (--spec <json> | --specfile <path>) [--seed <text>] [--out <path>]
35
37
  *
@@ -62,7 +64,19 @@ import {
62
64
  unaddressableForeignPackages,
63
65
  formatUnaddressableFinding,
64
66
  } from "../engine/metadata-index.mjs";
65
- import { fetchNavigation, generateHugoConfig, writeHugoConfig } from "../engine/site-config.mjs";
67
+ import {
68
+ fetchNavigation,
69
+ generateHugoConfig,
70
+ HUGO_SOURCE,
71
+ writeHugoConfig,
72
+ } from "../engine/site-config.mjs";
73
+ import {
74
+ checkScriptFonts,
75
+ copyScriptFonts,
76
+ scriptFontFiles,
77
+ SITE_FONTS_DIR,
78
+ siteFontsDestination,
79
+ } from "../engine/script-font-delivery.mjs";
66
80
  import { renderItemFieldReference, renderItemFieldsPage } from "../engine/field-reference.mjs";
67
81
  import { checkDocLinks, checkDocIndex } from "../engine/docs-checks.mjs";
68
82
  import { checkArtSlotSizes } from "../engine/art-fields.mjs";
@@ -102,6 +116,14 @@ import { ENGINE_NOTE_SCHEMAS } from "../engine/note-schemas.mjs";
102
116
  import { schemaSubtypeOf } from "../engine/subtype-registry.mjs";
103
117
  import { NOTE_VOCABULARY } from "../engine/note-vocabulary.mjs";
104
118
  import { markupFindings } from "../engine/markup-validation.mjs";
119
+ import { runContext } from "../engine/content-scripts.mjs";
120
+ import {
121
+ checkLexiconNatives,
122
+ checkMapFonts,
123
+ languageNoteFindings,
124
+ lexiconEntries,
125
+ transliterate,
126
+ } from "../engine/map-lexicon.mjs";
105
127
  import { checkFormatting, checkPrettierConventions, lintMarkdown } from "../engine/prose-lint.mjs";
106
128
  import { lintProse, scoreProseTree } from "../engine/readability-lint.mjs";
107
129
  import { loadPackageBuildConfig } from "../config.mjs";
@@ -300,6 +322,8 @@ export function registerContentCommands(cli) {
300
322
  )
301
323
  .command(withIndexPreflight(mapCommand()))
302
324
  .command(withIndexPreflight(reachabilityCommand()))
325
+ .command(transliterateCommand())
326
+ .command(lexiconCommand())
303
327
  .command(addressesCommand())
304
328
  .command(withIndexPreflight(npcCommand()));
305
329
  }
@@ -461,7 +485,7 @@ function withBodyPreflight(
461
485
  : undefined;
462
486
  const records = session?.records ?? indexRecordsFor({ config, contentBase });
463
487
  if (session?.problems.length) {
464
- emitDiagnostic(session.problems[0]);
488
+ for (const problem of session.problems) emitDiagnostic(problem);
465
489
  process.exitCode = 1;
466
490
  return;
467
491
  }
@@ -1257,10 +1281,19 @@ function lintCommand() {
1257
1281
  assetsBase: config.paths.assets,
1258
1282
  });
1259
1283
 
1284
+ // Text marked with a language, a font or a map, checked against
1285
+ // the languages this package declares, the faces it installed
1286
+ // and the maps its notes declare.
1287
+ const runs = runContext(config, records);
1260
1288
  const findings = [
1261
1289
  ...index.notes.flatMap((note) =>
1262
- markupFindings(note.raw, note.file, session.syntax),
1290
+ markupFindings(note.raw, note.file, session.syntax, runs),
1263
1291
  ),
1292
+ // Each mapping note's faces, each language's native forms
1293
+ // and each language's `note`, against the faces and maps.
1294
+ ...index.notes.flatMap((note) => checkMapFonts(note, runs)),
1295
+ ...index.notes.flatMap((note) => checkLexiconNatives(note, runs)),
1296
+ ...languageNoteFindings({ ...config, configFile: packConfigPath() }, records),
1264
1297
  ...index.notes.flatMap(firstUseChecker),
1265
1298
  ...(names ? [names] : []),
1266
1299
  ...addresses.findings,
@@ -1891,6 +1924,38 @@ function pdfCommand() {
1891
1924
  };
1892
1925
  }
1893
1926
 
1927
+ /**
1928
+ * Copy the installed script fonts into the Hugo static tree, under a path that
1929
+ * names their version, and drop any other version's copy.
1930
+ *
1931
+ * A package that has not installed `@heroiclands/fonts`, or sets no text in
1932
+ * one of its faces, gets no copy; a package missing a file its fonts depend on
1933
+ * gets a located error and a non-zero exit.
1934
+ *
1935
+ * @param {object} config - The resolved content configuration.
1936
+ * @returns {void}
1937
+ */
1938
+ function stageSiteFonts(config) {
1939
+ const root = path.join(config.rootDir, HUGO_SOURCE, "static", SITE_FONTS_DIR);
1940
+ fs.rmSync(root, { recursive: true, force: true });
1941
+ const found = scriptFontFiles(config, ["woff2"]);
1942
+ if (!found) return;
1943
+ const findings = checkScriptFonts(config);
1944
+ for (const finding of findings) emitDiagnostic(finding);
1945
+ if (findings.length) {
1946
+ process.exitCode = 1;
1947
+ return;
1948
+ }
1949
+ const dest = path.join(
1950
+ config.rootDir,
1951
+ HUGO_SOURCE,
1952
+ "static",
1953
+ siteFontsDestination(found.version),
1954
+ );
1955
+ const count = copyScriptFonts(config, dest, ["woff2"]);
1956
+ log.info(`wrote ${count} script font file(s) to ${path.relative(process.cwd(), dest)}`);
1957
+ }
1958
+
1894
1959
  /**
1895
1960
  * `package-build site` — publish the content tree as a website.
1896
1961
  *
@@ -2077,6 +2142,7 @@ function siteCommand() {
2077
2142
  );
2078
2143
  const { file } = writeHugoConfig(config, hugo);
2079
2144
  log.info(`wrote ${path.relative(process.cwd(), file)}`);
2145
+ stageSiteFonts(config);
2080
2146
  } catch (err) {
2081
2147
  reportFailure(err);
2082
2148
  process.exitCode = 1;
@@ -2241,6 +2307,201 @@ function mapCommand() {
2241
2307
  };
2242
2308
  }
2243
2309
 
2310
+ /**
2311
+ * The run context a mapping command reads: the content index's maps and
2312
+ * lexicons, and the configured languages and installed faces, exactly as the
2313
+ * build has them.
2314
+ *
2315
+ * @returns {{config: object, ctx: object}|undefined} The context, or
2316
+ * `undefined` when the content tree could not be read whole, which is
2317
+ * reported.
2318
+ */
2319
+ function mappingContext() {
2320
+ const config = loadPackConfig();
2321
+ const problems = [];
2322
+ const records = indexRecordsFor({
2323
+ contentBase: config.paths.content,
2324
+ config,
2325
+ skipDirectories: config.skipDirectories,
2326
+ problems,
2327
+ });
2328
+ for (const problem of problems) emitDiagnostic(problem);
2329
+ if (problems.length) return undefined;
2330
+ return { config, ctx: runContext(config, records) };
2331
+ }
2332
+
2333
+ /**
2334
+ * Report what a mapping command was given that names nothing: an unknown
2335
+ * language at the configuration's `languages`, and an unknown map or face at
2336
+ * the content tree, where mapping notes are. A word the rows cannot
2337
+ * capitalise is in the passage given, so it has no file.
2338
+ *
2339
+ * @param {object} config - The resolved configuration.
2340
+ * @param {{problem: string, about: string}} found - What is wrong.
2341
+ * @param {string} [lang] - The language asked for.
2342
+ */
2343
+ function reportMapping(config, found) {
2344
+ if (found.about === "lang") {
2345
+ const file = packConfigPath();
2346
+ let text;
2347
+ try {
2348
+ text = fs.readFileSync(file, "utf8");
2349
+ } catch {
2350
+ text = undefined;
2351
+ }
2352
+ emitDiagnostic({
2353
+ file,
2354
+ ...(text ? positionOfYamlPath(text, ["languages"], { key: true }) : {}),
2355
+ severity: "error",
2356
+ message: found.problem,
2357
+ });
2358
+ } else if (found.about === "word")
2359
+ // A word of the passage given, which sits in no file.
2360
+ emitDiagnostic({ severity: "error", message: found.problem });
2361
+ else emitDiagnostic({ file: config.paths.content, severity: "error", message: found.problem });
2362
+ process.exitCode = 1;
2363
+ }
2364
+
2365
+ /**
2366
+ * `package-build transliterate <map> [text..]` — map a passage exactly as the
2367
+ * build maps a run marked with the same `map=`, `lang=` and `font=`.
2368
+ *
2369
+ * @returns {object} The yargs command module.
2370
+ */
2371
+ // eslint-disable-next-line
2372
+ function transliterateCommand() {
2373
+ return {
2374
+ command: "transliterate <map> [text..]",
2375
+ describe: "Map a passage into a script exactly as the build maps a marked run",
2376
+ builder: (yargs) =>
2377
+ yargs
2378
+ .positional("map", {
2379
+ type: "string",
2380
+ describe: "The `data.map.name` of a mapping note",
2381
+ })
2382
+ .positional("text", {
2383
+ type: "string",
2384
+ array: true,
2385
+ describe: "The passage; `-` or nothing reads it from stdin",
2386
+ })
2387
+ .option("lang", {
2388
+ type: "string",
2389
+ describe: "A declared language, whose lexicon is read first",
2390
+ })
2391
+ .option("font", {
2392
+ type: "string",
2393
+ describe: "The face; required when the map lists more than one",
2394
+ })
2395
+ .option("hex", {
2396
+ type: "boolean",
2397
+ default: false,
2398
+ describe: "Print the code points, as `U+XXXX` separated by spaces",
2399
+ }),
2400
+ handler: (argv) => {
2401
+ try {
2402
+ const read = mappingContext();
2403
+ if (!read) {
2404
+ process.exitCode = 1;
2405
+ return;
2406
+ }
2407
+ const words = (argv.text ?? []).map(String);
2408
+ const text =
2409
+ !words.length || (words.length === 1 && words[0] === "-") ?
2410
+ fs.readFileSync(0, "utf8").replace(/\r?\n$/, "")
2411
+ : words.join(" ");
2412
+ const mapped = transliterate(
2413
+ read.ctx,
2414
+ { map: String(argv.map), lang: argv.lang, font: argv.font },
2415
+ text,
2416
+ );
2417
+ if ("problem" in mapped) {
2418
+ reportMapping(read.config, mapped);
2419
+ return;
2420
+ }
2421
+ console.log(
2422
+ argv.hex ?
2423
+ Array.from(
2424
+ mapped.text,
2425
+ (char) =>
2426
+ `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`,
2427
+ ).join(" ")
2428
+ : mapped.text,
2429
+ );
2430
+ } catch (err) {
2431
+ reportFailure(err);
2432
+ process.exitCode = 1;
2433
+ }
2434
+ },
2435
+ };
2436
+ }
2437
+
2438
+ /**
2439
+ * `package-build lexicon <language> --map <map>` — every word of a language's
2440
+ * lexicon with its native form under a map, as the build writes it.
2441
+ *
2442
+ * @returns {object} The yargs command module.
2443
+ */
2444
+ // eslint-disable-next-line
2445
+ function lexiconCommand() {
2446
+ return {
2447
+ command: "lexicon <language>",
2448
+ describe: "List a language's words with their native forms under a map",
2449
+ builder: (yargs) =>
2450
+ yargs
2451
+ .positional("language", {
2452
+ type: "string",
2453
+ describe: "A declared language whose `note` names its language note",
2454
+ })
2455
+ .option("map", {
2456
+ type: "string",
2457
+ demandOption: true,
2458
+ describe: "The `data.map.name` of a mapping note",
2459
+ })
2460
+ .option("font", {
2461
+ type: "string",
2462
+ describe: "The face; required when the map lists more than one",
2463
+ })
2464
+ .option("json", {
2465
+ type: "boolean",
2466
+ default: false,
2467
+ describe: "Print a JSON array rather than tab-separated lines",
2468
+ }),
2469
+ handler: (argv) => {
2470
+ try {
2471
+ const read = mappingContext();
2472
+ if (!read) {
2473
+ process.exitCode = 1;
2474
+ return;
2475
+ }
2476
+ const listed = lexiconEntries(read.ctx, {
2477
+ lang: String(argv.language),
2478
+ map: String(argv.map),
2479
+ font: argv.font,
2480
+ });
2481
+ if ("problem" in listed) {
2482
+ reportMapping(read.config, listed);
2483
+ return;
2484
+ }
2485
+ if (argv.json) console.log(JSON.stringify(listed.entries, null, 2));
2486
+ else
2487
+ for (const entry of listed.entries)
2488
+ console.log(
2489
+ [
2490
+ entry.form,
2491
+ entry.native,
2492
+ entry.exception ? "exception" : "rows",
2493
+ entry.class ?? "",
2494
+ entry.gloss ?? "",
2495
+ ].join("\t"),
2496
+ );
2497
+ } catch (err) {
2498
+ reportFailure(err);
2499
+ process.exitCode = 1;
2500
+ }
2501
+ },
2502
+ };
2503
+ }
2504
+
2244
2505
  /**
2245
2506
  * `package-build reachability <dir> [file]` — check that a corpus reads through.
2246
2507
  *
@@ -96,6 +96,11 @@ import { loadPackConfig, packConfigPath, resolveConfigFile } from "../engine/pac
96
96
  import { cleanBuildArtifacts, resetStage, stageAssets } from "../stage.mjs";
97
97
  import { rowsStagingPictureRoots, stagePackageAssets } from "../engine/asset-shipping.mjs";
98
98
  import { baseStyleStageEntry } from "../engine/base-styles.mjs";
99
+ import {
100
+ checkScriptFonts,
101
+ copyScriptFonts,
102
+ STAGED_FONTS_DIR,
103
+ } from "../engine/script-font-delivery.mjs";
99
104
  import {
100
105
  CDN_ENV,
101
106
  cdnCredentials,
@@ -576,6 +581,26 @@ function assetsCommand() {
576
581
  count += staged.entries;
577
582
  files += staged.files;
578
583
  }
584
+
585
+ // The installed fonts, when the package has installed the fonts
586
+ // package and a note sets text in one of its faces: the
587
+ // stylesheet, the WOFF2 files and the licence, which the
588
+ // manifest's `styles` names.
589
+ const fontFindings = checkScriptFonts(packConfig);
590
+ if (fontFindings.length) {
591
+ const error = new Error(fontFindings.map(formatDiagnostic).join("\n"));
592
+ error.located = true;
593
+ die(error);
594
+ }
595
+ const fonts = copyScriptFonts(
596
+ packConfig,
597
+ path.resolve(config.rootDir, config.stageDir, STAGED_FONTS_DIR),
598
+ ["woff2"],
599
+ );
600
+ if (fonts) {
601
+ count += 1;
602
+ files += fonts;
603
+ }
579
604
  console.log(`✅ Static assets staged (${count} entries, ${files} files).`);
580
605
  }),
581
606
  };
@@ -70,6 +70,7 @@ import { normalizeCurrentDate } from "./engine/current-date.mjs";
70
70
  // Leaves with no local imports of their own, so naming them here cannot close
71
71
  // a cycle around a consumer's config file (see `engine/pack-config.mjs`).
72
72
  import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./engine/address-charset.mjs";
73
+ import { isLanguageTag } from "./engine/language-tag.mjs";
73
74
  import { ASSET_TYPE_NAMES, shippingLicensesProblem } from "./engine/asset-types.mjs";
74
75
  import { isReservedPackage } from "./engine/packages.mjs";
75
76
  import { EMPTY_ICON_REGISTRY, checkIconRegistry } from "./engine/content-icons.mjs";
@@ -571,6 +572,10 @@ export function publishesContentPages(config) {
571
572
  * resolved against it, so the build never
572
573
  * depends on the working directory.
573
574
  * @property {string} [currentDate] Setting-wide neutral current date; defaults to "720.1".
575
+ * @property {string} [defaultCalendar] Shortcode of the `lore` note with
576
+ * `subType: calendar` that prints the
577
+ * dates of a note no other rule gives a
578
+ * calendar.
574
579
  * @property {string} contentPackage Content package name — the address
575
580
  * namespace every note in this
576
581
  * repository is published under.
@@ -610,6 +615,9 @@ export function publishesContentPages(config) {
610
615
  * `{ system, builders }` registries
611
616
  * instead, and the accepted type
612
617
  * vocabulary is their union.
618
+ * @property {Record<string, LanguageSpec>} [languages] The languages a
619
+ * note may tag text with, by short
620
+ * name. Default `{}`.
613
621
  * @property {PackSpec[]} packs Packs to compile. More than one entry
614
622
  * may share a `type`: a note then names
615
623
  * the pack it belongs in with its
@@ -646,6 +654,8 @@ export function publishesContentPages(config) {
646
654
  *
647
655
  * @typedef {object} ContentBuildConfig
648
656
  * @property {string} currentDate Setting-wide neutral current date.
657
+ * @property {string|null} defaultCalendar The calendar of last resort's
658
+ * shortcode, `null` when none is named.
649
659
  * @property {string} rootDir
650
660
  * @property {string} contentPackage
651
661
  * @property {string|null} foundryPackage `null` for a `documentation`
@@ -713,6 +723,8 @@ export function publishesContentPages(config) {
713
723
  * fonts this package ships and the names it
714
724
  * draws from them; empty when it declares
715
725
  * none.
726
+ * @property {Readonly<Record<string, Readonly<LanguageSpec>>>} languages
727
+ * Frozen; `{}` when absent.
716
728
  * @property {readonly Readonly<ResolvedPackSpec>[]} packs
717
729
  * @property {readonly string[]} packDirectories Derived: every pack directory
718
730
  * the build produces, in compile order —
@@ -730,6 +742,7 @@ const CONFIG_KEYS = [
730
742
  "rootDir",
731
743
  "contentPackage",
732
744
  "currentDate",
745
+ "defaultCalendar",
733
746
  "foundryPackage",
734
747
  "homepage",
735
748
  "authors",
@@ -740,6 +753,7 @@ const CONFIG_KEYS = [
740
753
  "skipDirectories",
741
754
  "forbidGeneratedArt",
742
755
  "icons",
756
+ "languages",
743
757
  "packs",
744
758
  "docs",
745
759
  "site",
@@ -912,6 +926,88 @@ function fail(field, problem) {
912
926
  throw Object.assign(new TypeError(`package-build config: \`${field}\` ${problem}.`), { field });
913
927
  }
914
928
 
929
+ /**
930
+ * The configured `defaultCalendar`: a calendar note's shortcode, or `null`.
931
+ * Whether it names a calendar note is checked against the content tree when
932
+ * the content index is built.
933
+ *
934
+ * @param {unknown} value - The configured value.
935
+ * @returns {string|null} The shortcode.
936
+ */
937
+ function normalizeDefaultCalendar(value) {
938
+ if (value === undefined || value === null) return null;
939
+ if (typeof value !== "string" || !isAddressSegment(value))
940
+ fail(
941
+ "defaultCalendar",
942
+ "must be the shortcode of a calendar note — lowercase letters and digits",
943
+ );
944
+ return /** @type {string} */ (value);
945
+ }
946
+
947
+ /**
948
+ * One entry of `languages`.
949
+ *
950
+ * @typedef {object} LanguageSpec
951
+ * @property {string} tag Its BCP 47 tag. A constructed language takes
952
+ * `art-x-<name>`.
953
+ * @property {string} [note] The Address of its language note, whose
954
+ * `data.words` is the lexicon a mapped run in the
955
+ * language reads first.
956
+ */
957
+
958
+ /**
959
+ * What a language name may be: lowercase letters, digits and single hyphens,
960
+ * starting with a letter. Lowercase keeps two names from differing only in
961
+ * case, which an index keyed by them cannot tell apart.
962
+ */
963
+ const LANGUAGE_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
964
+
965
+ /**
966
+ * Check `languages` and freeze it.
967
+ *
968
+ * @param {unknown} value - The configured mapping.
969
+ * @returns {Readonly<Record<string, Readonly<LanguageSpec>>>} The frozen
970
+ * mapping; `{}` when absent.
971
+ */
972
+ function normalizeLanguages(value) {
973
+ if (value === undefined || value === null) return Object.freeze({});
974
+ if (!isPlainObject(value)) fail("languages", "must be a mapping of language name to `{tag}`");
975
+ const out = {};
976
+ for (const [name, entry] of Object.entries(/** @type {object} */ (value))) {
977
+ const where = `languages.${name}`;
978
+ if (!LANGUAGE_NAME.test(name))
979
+ fail(
980
+ where,
981
+ "is not a name: a name is lowercase letters, digits and single hyphens, " +
982
+ "starting with a letter",
983
+ );
984
+ if (!isPlainObject(entry)) fail(where, "must be a mapping with `tag`");
985
+ rejectUnknownKeys(entry, ["tag", "note"], `${where}.`);
986
+ const { tag, note } = /** @type {Record<string, unknown>} */ (entry);
987
+ if (tag === undefined || tag === null || tag === "")
988
+ fail(
989
+ `${where}.tag`,
990
+ "must be a BCP 47 language tag, such as `art-x-vyl` for a constructed language",
991
+ );
992
+ if (!isLanguageTag(tag))
993
+ fail(
994
+ `${where}.tag`,
995
+ `${String(tag)} is not a valid BCP 47 language tag; a constructed ` +
996
+ "language takes `art-x-<name>`",
997
+ );
998
+ if (note !== undefined && (typeof note !== "string" || note.trim() === ""))
999
+ fail(
1000
+ `${where}.note`,
1001
+ "must be the Address of a language note, such as `skill-vylarilng`",
1002
+ );
1003
+ out[name] = Object.freeze({
1004
+ tag: /** @type {string} */ (tag),
1005
+ ...(note === undefined ? {} : { note: /** @type {string} */ (note) }),
1006
+ });
1007
+ }
1008
+ return Object.freeze(out);
1009
+ }
1010
+
915
1011
  /**
916
1012
  * @param {object} object
917
1013
  * @param {readonly string[]} allowed
@@ -2973,6 +3069,7 @@ export function defineConfig(config) {
2973
3069
  fail("currentDate", error.message);
2974
3070
  }
2975
3071
  })(),
3072
+ defaultCalendar: normalizeDefaultCalendar(input.defaultCalendar),
2976
3073
  contentPackage: requireContentPackage(input.contentPackage, docEntryTypes, {
2977
3074
  rootDir,
2978
3075
  packageKind,
@@ -3049,6 +3146,7 @@ export function defineConfig(config) {
3049
3146
  skipDirectories: Object.freeze(skipDirectories),
3050
3147
  forbidGeneratedArt,
3051
3148
  icons: normalizeIcons(input.icons, rootDir),
3149
+ languages: normalizeLanguages(input.languages),
3052
3150
  packs: Object.freeze(packs),
3053
3151
  packDirectories: Object.freeze(packDirectories),
3054
3152
  docs: normalizeDocs(input.docs),
package/docs/api.md CHANGED
@@ -723,7 +723,7 @@ resolved value: the culture a place or affiliation inherits through
723
723
 
724
724
  ### `engine.markdownExpressions`
725
725
 
726
- `renderMarkdownExpressions(body, { fm, dates, sqlResults, figures, file, bodyLine })` expands scalar frontmatter references and registered Handlebars helpers outside Markdown code. The `dateformat` helper uses `formatDateInCalendar`; `gt`, `gte`, `lt`, `lte`, `eq`, `and`, `or`, and `not` evaluate explicit comparisons. The `sql` helper reads a prepared scalar result, which must contain one row and one column. The `ref` helper links to a captioned item by its anchor — `"#thorn"` on the same note — in `number` (the default, and admitted explicitly), `full` or `title` form; `figures` is a `Map` of this note's own figures, by id, to `{label, caption, hasCaption}`, and an address naming another note is reported as unresolved rather than looked up. `sqlQueriesInMarkdown` discovers SQL calls, including nested calls; `prepareInlineSqlExpressions` evaluates them through the same DuckDB connection as SQL tables. `EXPRESSION_HELPERS` is the one list of helpers: the engine registers from it and the authoring document is checked against it, so each name there carries the parameters it takes and a sentence on what it does. The renderer returns `{ markdown, findings }`, with source positions for invalid expressions. Site, Foundry and PDF passes invoke it after generated tables expand and before links resolve. Hugo shortcodes remain literal for the site renderer.
726
+ `renderMarkdownExpressions(body, { fm, dates, sqlResults, figures, file, bodyLine })` expands scalar frontmatter references and registered Handlebars helpers outside Markdown code. The `dateformat` helper uses `formatDateInCalendar`, in the calendar it names or else the note's resolved calendar, which it reads from `dates.noteCalendars` — the map a reckoning context built over content-index records carries; `gt`, `gte`, `lt`, `lte`, `eq`, `and`, `or`, and `not` evaluate explicit comparisons. The `sql` helper reads a prepared scalar result, which must contain one row and one column. The `ref` helper links to a captioned item by its anchor — `"#thorn"` on the same note — in `number` (the default, and admitted explicitly), `full` or `title` form; `figures` is a `Map` of this note's own figures, by id, to `{label, caption, hasCaption}`, and an address naming another note is reported as unresolved rather than looked up. `sqlQueriesInMarkdown` discovers SQL calls, including nested calls; `prepareInlineSqlExpressions` evaluates them through the same DuckDB connection as SQL tables. `EXPRESSION_HELPERS` is the one list of helpers: the engine registers from it and the authoring document is checked against it, so each name there carries the parameters it takes and a sentence on what it does. The renderer returns `{ markdown, findings }`, with source positions for invalid expressions. Site, Foundry and PDF passes invoke it after generated tables expand and before links resolve. Hugo shortcodes remain literal for the site renderer.
727
727
 
728
728
  | Export | Signature | Returns | Use it when |
729
729
  | --------------------------- | ------------------------------------------ | ---------------------- | ------------------------------------------------ |
@@ -736,10 +736,13 @@ resolved value: the culture a place or affiliation inherits through
736
736
 
737
737
  The corpus registry anchors each calendar's year 1, day 1 to its canonical `data.epoch` and maps every declared era to that calendar. Exactly one `start: null` era covers earlier years and counts backwards; exactly one `start: 1` era begins the forward count. Other starts are distinct positive calendar years. Numeric order determines bounds, irrespective of array order. Duplicate starts and markers, missing required eras, and invalid epochs are findings. Short qualifiers are ambiguous when they name eras in several packages; a full Address selects one.
738
738
 
739
- | Export | Signature | Returns | Use it when |
740
- | ------------------------- | --------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------- |
741
- | `resolveReckoningMarkers` | `resolveReckoningMarkers(index, daysPerYear)` | `{eras, markers, findings}` | resolving authored eras, their markers, and their bounds |
742
- | `reckoningContext` | `reckoningContext(index)` | `{eras, markers, daysPerYear, findings}` | sharing one resolved date context across checks on a corpus |
739
+ | Export | Signature | Returns | Use it when |
740
+ | ------------------------- | --------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
741
+ | `resolveReckoningMarkers` | `resolveReckoningMarkers(index, daysPerYear)` | `{eras, markers, findings}` | resolving authored eras, their markers, and their bounds |
742
+ | `reckoningContext` | `reckoningContext(index)` | `{eras, markers, daysPerYear, findings, noteCalendars}` | sharing one resolved date context across checks on a corpus |
743
+ | `noteCalendarMap` | `noteCalendarMap(notes)` | `Map<string, string>` | reading each record's `resolvedCalendar` by note, as `reckoningContext` carries it in `noteCalendars` |
744
+ | `noteCalendarKey` | `noteCalendarKey(fm)` | `string \| undefined` | finding a note's entry in `noteCalendars` by its type and shortcode |
745
+ | `calendarAddress` | `calendarAddress(value)` | `string \| undefined` | reading a record's `resolvedCalendar`, tuple or string, as its canonical Address |
743
746
 
744
747
  ### `engine.calendarNotes`
745
748
 
@@ -2532,15 +2535,16 @@ The wildcard engine subpath exposes
2532
2535
  `@heroiclands/package-build/engine/dictionary-rows` for deriving the
2533
2536
  `dictionary` and `terms` SQL relations from content-index records:
2534
2537
 
2535
- | Export | Signature | Returns | Use it when |
2536
- | ----------------------- | ----------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------- |
2537
- | `DICTIONARY_COLUMNS` | `const DICTIONARY_COLUMNS` | A readonly list of column names | Declaring the schema of a language's dictionary. |
2538
- | `TERM_COLUMNS` | `const TERM_COLUMNS` | A readonly list of column names | Declaring the schema of owner-specific glossary terms. |
2539
- | `ETYMOLOGY_KEYS` | `const ETYMOLOGY_KEYS` | A readonly list of keys | Reading the structure of a dictionary word's etymology. |
2540
- | `dictionaryLetter` | `dictionaryLetter(text)` | `string` | Deriving a heading letter from a sort key. |
2541
- | `dictionaryEntryAnchor` | `dictionaryEntryAnchor(language, name)` | `string` | Deriving the stable entry anchor for a word in a language's dictionary. |
2542
- | `dictionaryAndTermRows` | `dictionaryAndTermRows(records, all = records)` | `{ dictionary: object[], terms: object[] }` | Deriving dictionary words and glossary terms for the selected records. |
2543
- | `serializeRows` | `serializeRows(rows)` | `string` | Writing deterministic JSON Lines with keys sorted at every depth. |
2538
+ | Export | Signature | Returns | Use it when |
2539
+ | ----------------------- | ----------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------- |
2540
+ | `DICTIONARY_COLUMNS` | `const DICTIONARY_COLUMNS` | A readonly list of column names | Declaring the schema of a language's dictionary. |
2541
+ | `TERM_COLUMNS` | `const TERM_COLUMNS` | A readonly list of column names | Declaring the schema of owner-specific glossary terms. |
2542
+ | `ETYMOLOGY_KEYS` | `const ETYMOLOGY_KEYS` | A readonly list of keys | Reading the structure of a dictionary word's etymology. |
2543
+ | `dictionaryLetter` | `dictionaryLetter(text)` | `string` | Deriving a heading letter from a sort key. |
2544
+ | `dictionaryEntryAnchor` | `dictionaryEntryAnchor(language, name)` | `string` | Deriving the stable entry anchor for a word in a language's dictionary. |
2545
+ | `dictionaryAndTermRows` | `dictionaryAndTermRows(records, all = records)` | `{ dictionary: object[], terms: object[] }` | Deriving dictionary words and glossary terms for the selected records. |
2546
+ | `serializeRows` | `serializeRows(rows)` | `string` | Writing deterministic JSON Lines with keys sorted at every depth. |
2547
+ | `pluralOf` | `pluralOf(word, grammar)` | `string \| null` | Deriving a noun's plural: its own `plural`, else the first `grammar.plural` row whose ending fits. |
2544
2548
 
2545
2549
  `dictionaryEntryAnchor` takes the language's canonical Address and the
2546
2550
  word's form. Its `word-` anchor is derived from the full SHA-256 digest of