@heroiclands/package-build 23.12.6 → 23.12.8

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 (94) hide show
  1. package/CHANGELOG.md +18 -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 +72 -0
  6. package/docs/api.md +10 -9
  7. package/docs/authoring/assets.md +1 -1
  8. package/docs/authoring/links-and-markup.md +439 -47
  9. package/docs/commands.md +137 -3
  10. package/docs/configuration.md +73 -3
  11. package/docs/content-format.md +3 -1
  12. package/docs/guides/book.md +11 -0
  13. package/docs/guides/module-project.md +16 -0
  14. package/docs/guides/site.md +19 -0
  15. package/docs/reference/format-details.md +378 -6
  16. package/docs/reference/note-types.md +36 -11
  17. package/e2e.mjs +1 -1
  18. package/engine/asset-shipping.mjs +22 -8
  19. package/engine/base-compiler.mjs +2 -0
  20. package/engine/bundles.mjs +7 -2
  21. package/engine/compile-corpus.mjs +3 -1
  22. package/engine/content-charset.mjs +21 -2
  23. package/engine/content-format.yaml +1 -0
  24. package/engine/content-index.mjs +12 -0
  25. package/engine/content-poetry.mjs +22 -4
  26. package/engine/content-scripts.mjs +1219 -0
  27. package/engine/content-session.mjs +2 -0
  28. package/engine/data-keys.mjs +5 -1
  29. package/engine/dictionary-layout.mjs +18 -12
  30. package/engine/dictionary-rows.mjs +11 -2
  31. package/engine/helpers.mjs +8 -2
  32. package/engine/index-records.mjs +1 -0
  33. package/engine/journals.mjs +14 -3
  34. package/engine/language-data.mjs +137 -1
  35. package/engine/language-tag.mjs +103 -0
  36. package/engine/language-terms.mjs +16 -4
  37. package/engine/map-lexicon.mjs +762 -0
  38. package/engine/markup-validation.mjs +17 -3
  39. package/engine/names.mjs +1 -1
  40. package/engine/note-vocabulary.mjs +113 -0
  41. package/engine/pdf-build.mjs +28 -10
  42. package/engine/pdf-render.mjs +116 -11
  43. package/engine/run-rows.mjs +119 -0
  44. package/engine/run-words.mjs +266 -0
  45. package/engine/scenes.mjs +1 -0
  46. package/engine/script-font-delivery.mjs +321 -0
  47. package/engine/script-fonts.mjs +172 -0
  48. package/engine/site-build.mjs +10 -3
  49. package/engine/site-config.mjs +5 -0
  50. package/engine/site-root.mjs +19 -5
  51. package/engine/sql-tables.mjs +111 -1
  52. package/engine/text-maps.mjs +607 -0
  53. package/engine/verb-pattern.mjs +99 -0
  54. package/engine/word-pattern.mjs +27 -0
  55. package/engine/word-rows.mjs +8 -2
  56. package/engine/word-terms.mjs +44 -0
  57. package/hugo-theme/layouts/_default/baseof.html +3 -0
  58. package/hugo-theme/static/css/style.css +10 -0
  59. package/manifest.mjs +9 -1
  60. package/package.json +2 -2
  61. package/types/content-config.d.mts +26 -0
  62. package/types/e2e.d.mts +2 -2
  63. package/types/engine/address-fields.d.mts +1 -1
  64. package/types/engine/address-values.d.mts +1 -1
  65. package/types/engine/asset-shipping.d.mts +5 -0
  66. package/types/engine/compile-corpus.d.mts +2 -1
  67. package/types/engine/content-format-check.d.mts +1 -1
  68. package/types/engine/content-index.d.mts +11 -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/helpers.d.mts +5 -1
  74. package/types/engine/journals.d.mts +7 -2
  75. package/types/engine/language-data.d.mts +2 -0
  76. package/types/engine/language-tag.d.mts +8 -0
  77. package/types/engine/language-terms.d.mts +2 -1
  78. package/types/engine/map-lexicon.d.mts +274 -0
  79. package/types/engine/markup-validation.d.mts +7 -2
  80. package/types/engine/note-vocabulary.d.mts +1 -1
  81. package/types/engine/pdf-build.d.mts +26 -3
  82. package/types/engine/pdf-coverage.d.mts +1 -1
  83. package/types/engine/pdf-render.d.mts +17 -2
  84. package/types/engine/run-rows.d.mts +89 -0
  85. package/types/engine/run-words.d.mts +86 -0
  86. package/types/engine/script-font-delivery.d.mts +134 -0
  87. package/types/engine/script-fonts.d.mts +168 -0
  88. package/types/engine/site-build.d.mts +2 -1
  89. package/types/engine/site-root.d.mts +11 -3
  90. package/types/engine/text-maps.d.mts +175 -0
  91. package/types/engine/verb-pattern.d.mts +65 -0
  92. package/types/engine/word-pattern.d.mts +13 -0
  93. package/types/engine/word-terms.d.mts +6 -0
  94. package/types/release.d.mts +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.12.8
4
+
5
+ ### Patch Changes
6
+
7
+ A `packageBuild.assets` row that copies files into `icons/` or `images/` now ships them beside the staged pictures instead of having them erased. A shipped picture whose path matches a file a row already staged is an error, and the row's file is left as it was.
8
+
9
+ ## 23.12.7
10
+
11
+ ### Patch Changes
12
+
13
+ **Writing in the setting's languages and scripts**
14
+
15
+ - 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.
16
+ - 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.
17
+ - `package-build transliterate` and `package-build lexicon` show a passage or a word exactly as a build sets it.
18
+ - Converted text is checked against its language, and an unknown word or unregistered name is reported where it is written.
19
+ - A language's present endings can replace its infinitive ending, as Vylari's do.
20
+
3
21
  ## 23.12.6
4
22
 
5
23
  ### 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";
@@ -614,6 +615,9 @@ export function publishesContentPages(config) {
614
615
  * `{ system, builders }` registries
615
616
  * instead, and the accepted type
616
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 `{}`.
617
621
  * @property {PackSpec[]} packs Packs to compile. More than one entry
618
622
  * may share a `type`: a note then names
619
623
  * the pack it belongs in with its
@@ -719,6 +723,8 @@ export function publishesContentPages(config) {
719
723
  * fonts this package ships and the names it
720
724
  * draws from them; empty when it declares
721
725
  * none.
726
+ * @property {Readonly<Record<string, Readonly<LanguageSpec>>>} languages
727
+ * Frozen; `{}` when absent.
722
728
  * @property {readonly Readonly<ResolvedPackSpec>[]} packs
723
729
  * @property {readonly string[]} packDirectories Derived: every pack directory
724
730
  * the build produces, in compile order —
@@ -747,6 +753,7 @@ const CONFIG_KEYS = [
747
753
  "skipDirectories",
748
754
  "forbidGeneratedArt",
749
755
  "icons",
756
+ "languages",
750
757
  "packs",
751
758
  "docs",
752
759
  "site",
@@ -937,6 +944,70 @@ function normalizeDefaultCalendar(value) {
937
944
  return /** @type {string} */ (value);
938
945
  }
939
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
+
940
1011
  /**
941
1012
  * @param {object} object
942
1013
  * @param {readonly string[]} allowed
@@ -3075,6 +3146,7 @@ export function defineConfig(config) {
3075
3146
  skipDirectories: Object.freeze(skipDirectories),
3076
3147
  forbidGeneratedArt,
3077
3148
  icons: normalizeIcons(input.icons, rootDir),
3149
+ languages: normalizeLanguages(input.languages),
3078
3150
  packs: Object.freeze(packs),
3079
3151
  packDirectories: Object.freeze(packDirectories),
3080
3152
  docs: normalizeDocs(input.docs),
package/docs/api.md CHANGED
@@ -2535,15 +2535,16 @@ The wildcard engine subpath exposes
2535
2535
  `@heroiclands/package-build/engine/dictionary-rows` for deriving the
2536
2536
  `dictionary` and `terms` SQL relations from content-index records:
2537
2537
 
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. |
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. |
2547
2548
 
2548
2549
  `dictionaryEntryAnchor` takes the language's canonical Address and the
2549
2550
  word's form. Its `word-` anchor is derived from the full SHA-256 digest of
@@ -47,7 +47,7 @@ role: portrait
47
47
 
48
48
  ## What the Foundry package ships
49
49
 
50
- The repository keeps every picture; the Foundry package carries a filtered copy, laid out flat. A picture ships when its record says `ai: false` and its licence is one of `LicenseRef-PublicDomain`, `CC0-1.0`, `CC0`, `CC-BY-3.0`, `CC-BY-4.0`, `CC-BY-SA-4.0` or `LicenseRef-NounProject-Pro`, together with any licence the package adds under `packageBuild.shippingLicenses`. A shipped picture sits at `icons/<basename>` or `images/<basename>` at the package root, whatever directories it is kept in here, and the package's asset set, `asset-sets/default.json`, lists what it ships.
50
+ The repository keeps every picture; the Foundry package carries a filtered copy, laid out flat. A picture ships when its record says `ai: false` and its licence is one of `LicenseRef-PublicDomain`, `CC0-1.0`, `CC0`, `CC-BY-3.0`, `CC-BY-4.0`, `CC-BY-SA-4.0` or `LicenseRef-NounProject-Pro`, together with any licence the package adds under `packageBuild.shippingLicenses`. A shipped picture sits at `icons/<basename>` or `images/<basename>` at the package root, whatever directories it is kept in here, and the package's asset set, `asset-sets/default.json`, lists what it ships. A `packageBuild.assets` row may copy other files under `icons/` or `images/`; the shipped pictures are written beside them, and a row's file at the same path as a shipped picture is an error.
51
51
 
52
52
  A note may still name a picture the package does not ship. Its compiled document draws the package's stand-in instead — `icons/missing.<ext>` or `images/missing.<ext>`, which you provide as an ordinary human-made picture licensed to ship, addressed `<package>-none-icon-missing` and `<package>-none-image-missing` — and keeps the real picture's address, so a world that holds the real file can point back at it. A package that leaves out a picture a note names, and ships no stand-in of that type, fails the build at the note. See [what a package ships](../reference/format-details.md#what-a-package-ships).
53
53