@heroiclands/package-build 24.0.1 → 24.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/assets/preview/gitea.ini +1 -1
  3. package/assets/styles/content-base.css +33 -0
  4. package/bin/content-commands.mjs +17 -17
  5. package/content-config.mjs +1 -52
  6. package/docs/api.md +20 -13
  7. package/docs/authoring/links-and-markup.md +165 -17
  8. package/docs/commands.md +20 -20
  9. package/docs/configuration.md +29 -69
  10. package/docs/content-format.md +4 -2
  11. package/docs/guides/site.md +3 -1
  12. package/docs/guides/writing-in-invented-scripts.md +68 -6
  13. package/docs/reference/format-details.md +160 -118
  14. package/docs/reference/note-types.md +49 -29
  15. package/docs/source-preview.md +2 -2
  16. package/engine/content-blocks.mjs +6 -2
  17. package/engine/content-charset.mjs +1 -0
  18. package/engine/content-format.yaml +2 -0
  19. package/engine/content-index.mjs +5 -1
  20. package/engine/content-render.mjs +2 -1
  21. package/engine/content-scripts.mjs +268 -21
  22. package/engine/content-scroll.mjs +389 -0
  23. package/engine/dictionary-layout.mjs +16 -3
  24. package/engine/font-outlines.mjs +394 -0
  25. package/engine/foreign-catalog.mjs +11 -122
  26. package/engine/language-data.mjs +154 -63
  27. package/engine/language-rows.mjs +35 -9
  28. package/engine/language-terms.mjs +39 -9
  29. package/engine/map-lexicon.mjs +31 -6
  30. package/engine/note-vocabulary.mjs +58 -6
  31. package/engine/pdf-build.mjs +9 -178
  32. package/engine/pdf-fonts.mjs +1 -1
  33. package/engine/pdf-render.mjs +52 -11
  34. package/engine/run-rows.mjs +45 -3
  35. package/engine/script-direction.mjs +67 -0
  36. package/engine/script-emphasis-terms.mjs +11 -3
  37. package/engine/script-font-delivery.mjs +42 -9
  38. package/engine/script-fonts.mjs +7 -0
  39. package/engine/site-reach.mjs +23 -0
  40. package/engine/site-render.mjs +18 -3
  41. package/engine/verse-metre.mjs +1 -0
  42. package/engine/written-in.mjs +208 -0
  43. package/package.json +2 -1
  44. package/site-theme/static/css/style.css +7 -0
  45. package/types/content-config.d.mts +1 -19
  46. package/types/engine/content-scripts.d.mts +31 -6
  47. package/types/engine/content-scroll.d.mts +179 -0
  48. package/types/engine/dictionary-layout.d.mts +5 -0
  49. package/types/engine/font-outlines.d.mts +67 -0
  50. package/types/engine/foreign-catalog.d.mts +0 -38
  51. package/types/engine/language-data.d.mts +13 -0
  52. package/types/engine/language-rows.d.mts +5 -4
  53. package/types/engine/language-terms.d.mts +7 -0
  54. package/types/engine/map-lexicon.d.mts +4 -3
  55. package/types/engine/pdf-build.d.mts +5 -27
  56. package/types/engine/pdf-fonts.d.mts +10 -0
  57. package/types/engine/run-rows.d.mts +28 -6
  58. package/types/engine/script-direction.d.mts +10 -0
  59. package/types/engine/script-emphasis-terms.d.mts +10 -3
  60. package/types/engine/script-font-delivery.d.mts +5 -3
  61. package/types/engine/script-fonts.d.mts +9 -0
  62. package/types/engine/site-reach.d.mts +11 -0
  63. package/types/engine/written-in.d.mts +76 -0
  64. package/engine/asset-replacement.mjs +0 -93
  65. package/types/engine/asset-replacement.d.mts +0 -22
@@ -21,9 +21,11 @@
21
21
  * - no skill of another subType states these keys;
22
22
  * - a tongue tag is lowercase letters and digits, optionally closing on
23
23
  * `:<tongue>`, and stands once; a tongue's `language` names a language note;
24
- * - every tag a register row or a letter row names is declared in
25
- * `data.tongues`, `translation` apart: `exonym:vylari` is declared by
26
- * `exonym:<tongue>`;
24
+ * - every tag a register row names is declared in `data.tongues`,
25
+ * `translation` apart: `exonym:vylari` is declared by `exonym:<tongue>`;
26
+ * - every tongue a letter row names is such a tag or the Address of a
27
+ * language note, and a spelling guide's, on a `doc` note, an Address (see
28
+ * {@link checkGuideSounds});
27
29
  * - an edge stands once in `data.sounds.edges`;
28
30
  * - a sound stands once in each mutation of `data.sounds.mutations`;
29
31
  * - a cluster stands in one group of `data.sounds.clusters.groups`, and a
@@ -432,6 +434,153 @@ function checkParticiples(participles, affixes, finding) {
432
434
  return out;
433
435
  }
434
436
 
437
+ /**
438
+ * The Address checks a note's data reads through, each finding located by
439
+ * `finding`.
440
+ *
441
+ * @param {object|undefined} index - The index; without `addressHit`, nothing
442
+ * resolves and nothing is reported.
443
+ * @param {(path: Array<string|number>, severity: string, message: string, key?: boolean) => object} finding -
444
+ * A finding at a path of the note's frontmatter.
445
+ * @returns {{resolve: Function, language: Function}} The checks.
446
+ */
447
+ function resolvers(index, finding) {
448
+ /**
449
+ * The note an Address names, or a finding where it names none.
450
+ *
451
+ * @param {unknown} value - The Address as the note holds it.
452
+ * @param {Array<string|number>} path - Where it sits.
453
+ * @param {string|undefined} type - The type a bare shortcode defaults to.
454
+ * @param {boolean} [key] - Whether the Address is a map key.
455
+ * @returns {{target?: object, key?: string, finding?: object}}
456
+ */
457
+ function resolve(value, path, type, key = false) {
458
+ if (!index?.addressHit || value === undefined || value === null) return {};
459
+ const canonical = addressKeyOf(value, index, type);
460
+ if (!canonical)
461
+ return {
462
+ finding: finding(
463
+ path,
464
+ "error",
465
+ `\`${label(path)}\` is not an Address: ${JSON.stringify(
466
+ isAddressTuple(value) ? renderAddress(value) : value,
467
+ )}`,
468
+ key,
469
+ ),
470
+ };
471
+ const target = index.addressHit(canonical);
472
+ if (!target)
473
+ return {
474
+ finding: finding(
475
+ path,
476
+ "error",
477
+ `\`${label(path)}\` names ${canonical}, which does not resolve in this ` +
478
+ "package or its declared dependencies",
479
+ key,
480
+ ),
481
+ };
482
+ return { target, key: canonical };
483
+ }
484
+
485
+ /** As {@link resolve}, and the target must be a language note. */
486
+ function language(value, path, key = false) {
487
+ const result = resolve(value, path, "skill", key);
488
+ if (result.finding) return [result.finding];
489
+ if (!result.target) return [];
490
+ const target = result.target.fm ?? result.target;
491
+ if (target.type === "skill" && target.subType === LANGUAGE_SUBTYPE) return [];
492
+ return [
493
+ finding(
494
+ path,
495
+ "error",
496
+ `\`${label(path)}\` names ${result.key}, which is not a skill whose subType is ` +
497
+ "language",
498
+ key,
499
+ ),
500
+ ];
501
+ }
502
+
503
+ return { resolve, language };
504
+ }
505
+
506
+ /**
507
+ * The findings on each `tongue` of a note's `data.sounds.letters` rows. A value
508
+ * is a tag the note's `data.tongues` declares, or else the Address of a
509
+ * language note; a `doc` note declares no tags, so its rows name language
510
+ * notes only. Without an index no Address resolves: a language note's value
511
+ * that is not a declared tag is then a finding, and a guide's is not checked.
512
+ *
513
+ * @param {unknown} letters - The rows.
514
+ * @param {object} context
515
+ * @param {Set<string>|undefined} context.declared - The tags `data.tongues`
516
+ * declares; `undefined` on a note that declares none.
517
+ * @param {object|undefined} context.index - The index Addresses resolve in.
518
+ * @param {(path: Array<string|number>, severity: string, message: string, key?: boolean) => object} context.finding -
519
+ * A finding at a path of the note's frontmatter.
520
+ * @returns {object[]} The findings, each at its value.
521
+ */
522
+ function letterTongueFindings(letters, { declared, index, finding }) {
523
+ const { resolve, language } = resolvers(index, finding);
524
+ const out = [];
525
+ listOf(letters).forEach((row, i) => {
526
+ if (!isMap(row) || row.tongue === undefined || row.tongue === null) return;
527
+ const at = ["data", "sounds", "letters", i, "tongue"];
528
+ const values = Array.isArray(row.tongue) ? row.tongue : [row.tongue];
529
+ values.forEach((value, j) => {
530
+ if (typeof value !== "string") return;
531
+ if (declared && tongueDeclared(value, declared)) return;
532
+ const path = Array.isArray(row.tongue) ? [...at, j] : at;
533
+ const resolved = resolve(value, path, "skill");
534
+ if (resolved.target) {
535
+ out.push(...language(value, path));
536
+ return;
537
+ }
538
+ if (declared)
539
+ out.push(
540
+ finding(
541
+ path,
542
+ "error",
543
+ `the tongue \`${value}\` is not declared in \`data.tongues\`` +
544
+ (index?.addressHit ? " and names no language note" : ""),
545
+ ),
546
+ );
547
+ else if (resolved.finding)
548
+ out.push(
549
+ finding(
550
+ path,
551
+ "error",
552
+ `\`${label(path)}\` names \`${value}\`, which is no language note's ` +
553
+ "Address; a spelling guide names each tongue by its language note",
554
+ ),
555
+ );
556
+ });
557
+ });
558
+ return out;
559
+ }
560
+
561
+ /**
562
+ * Check a `doc` note's `data.sounds`, a spelling guide's letters and marks:
563
+ * each letter row's `tongue` names language notes. The data-key check owns
564
+ * the keys, so a sound rule a guide does not state is refused there.
565
+ *
566
+ * @param {object} note - A `doc` note: `file`, `raw` and parsed `fm`.
567
+ * @param {object} [context]
568
+ * @param {object} [context.index] - The index, for whether an Address resolves.
569
+ * @returns {object[]} The findings.
570
+ */
571
+ export function checkGuideSounds(note, { index } = {}) {
572
+ const sounds = note?.fm?.data?.sounds;
573
+ if (!isMap(sounds)) return [];
574
+ const raw = note.raw ?? "";
575
+ const finding = (path, severity, message, key = false) => ({
576
+ file: note.file,
577
+ ...positionOfFrontmatterPath(raw, path, { key }),
578
+ severity,
579
+ message,
580
+ });
581
+ return letterTongueFindings(sounds.letters, { declared: undefined, index, finding });
582
+ }
583
+
435
584
  /**
436
585
  * Check a `skill` note's language data against what it means.
437
586
  *
@@ -542,60 +691,7 @@ export function checkLanguageData(note, { index, types } = {}) {
542
691
  ...checkAffixClasses(data.affixes, finding),
543
692
  );
544
693
 
545
- /**
546
- * The note an Address names, or a finding where it names none.
547
- *
548
- * @param {unknown} value - The Address as the note holds it.
549
- * @param {Array<string|number>} path - Where it sits.
550
- * @param {string|undefined} type - The type a bare shortcode defaults to.
551
- * @param {boolean} [key] - Whether the Address is a map key.
552
- * @returns {{target?: object, key?: string, finding?: object}}
553
- */
554
- function resolve(value, path, type, key = false) {
555
- if (!index?.addressHit || value === undefined || value === null) return {};
556
- const canonical = addressKeyOf(value, index, type);
557
- if (!canonical)
558
- return {
559
- finding: finding(
560
- path,
561
- "error",
562
- `\`${label(path)}\` is not an Address: ${JSON.stringify(
563
- isAddressTuple(value) ? renderAddress(value) : value,
564
- )}`,
565
- key,
566
- ),
567
- };
568
- const target = index.addressHit(canonical);
569
- if (!target)
570
- return {
571
- finding: finding(
572
- path,
573
- "error",
574
- `\`${label(path)}\` names ${canonical}, which does not resolve in this ` +
575
- "package or its declared dependencies",
576
- key,
577
- ),
578
- };
579
- return { target, key: canonical };
580
- }
581
-
582
- /** As {@link resolve}, and the target must be a language note. */
583
- function language(value, path, key = false) {
584
- const result = resolve(value, path, "skill", key);
585
- if (result.finding) return [result.finding];
586
- if (!result.target) return [];
587
- const target = result.target.fm ?? result.target;
588
- if (target.type === "skill" && target.subType === LANGUAGE_SUBTYPE) return [];
589
- return [
590
- finding(
591
- path,
592
- "error",
593
- `\`${label(path)}\` names ${result.key}, which is not a skill whose subType is ` +
594
- "language",
595
- key,
596
- ),
597
- ];
598
- }
694
+ const { resolve, language } = resolvers(index, finding);
599
695
 
600
696
  // ── Tongues ──────────────────────────────────────────────────────────────
601
697
  const declared = new Set();
@@ -668,12 +764,7 @@ export function checkLanguageData(note, { index, types } = {}) {
668
764
 
669
765
  // ── Sounds ───────────────────────────────────────────────────────────────
670
766
  const sounds = isMap(data.sounds) ? data.sounds : {};
671
- listOf(sounds.letters).forEach((row, i) => {
672
- if (!isMap(row) || row.tongue === undefined || row.tongue === null) return;
673
- const path = ["data", "sounds", "letters", i, "tongue"];
674
- if (Array.isArray(row.tongue)) undeclared(row.tongue, (j) => [...path, j]);
675
- else undeclared([row.tongue], () => path);
676
- });
767
+ findings.push(...letterTongueFindings(sounds.letters, { declared, index, finding }));
677
768
  const edgeAt = new Map();
678
769
  listOf(sounds.edges).forEach((row, i) => {
679
770
  if (!isMap(row) || typeof row.edge !== "string") return;
@@ -253,7 +253,7 @@ export const LANGUAGE_COLUMN_TYPES = Object.freeze({
253
253
  joining: { position: "BIGINT", first: LIST, second: LIST },
254
254
  joins: { position: "BIGINT", letters: LIST },
255
255
  marks: { position: "BIGINT", letters: LIST },
256
- skeleton: { position: "BIGINT", second: LIST },
256
+ skeleton: { position: "BIGINT", second: LIST, third: LIST },
257
257
  frames: {
258
258
  position: "BIGINT",
259
259
  class: LIST,
@@ -309,11 +309,14 @@ function languagesOf(records) {
309
309
  * @param {string} [options.prefix] - The prefix of a key sharing an identity
310
310
  * column's name.
311
311
  * @param {readonly string[]} [options.lists] - The keys always set as lists.
312
+ * @param {(records: Iterable<Record<string, any>>) => Array<[string, Record<string, any>]>} [options.sources] -
313
+ * The records stating the list, with their Addresses; the language records
314
+ * where unstated.
312
315
  * @returns {object[]} One row per entry, in record then entry order.
313
316
  */
314
- function listRows(records, read, keys, { prefix = "", lists = [] } = {}) {
317
+ function listRows(records, read, keys, { prefix = "", lists = [], sources = languagesOf } = {}) {
315
318
  const rows = [];
316
- for (const [language, record] of languagesOf(records)) {
319
+ for (const [language, record] of sources(records)) {
317
320
  const entries = read(isMap(record.data) ? record.data : {});
318
321
  if (!Array.isArray(entries)) continue;
319
322
  entries.forEach((entry, position) => {
@@ -328,6 +331,24 @@ function listRows(records, read, keys, { prefix = "", lists = [] } = {}) {
328
331
  return rows;
329
332
  }
330
333
 
334
+ /**
335
+ * Each record stating letters and marks, with its Address: every language
336
+ * record, and every `doc` record whose `data.sounds` is a spelling guide's.
337
+ *
338
+ * @param {Iterable<Record<string, any>>} records - Index records.
339
+ * @returns {Array<[string, Record<string, any>]>}
340
+ */
341
+ function soundSourcesOf(records) {
342
+ const list = [...records];
343
+ const out = languagesOf(list);
344
+ for (const record of list) {
345
+ if (record?.type !== "doc" || record.documents || !isMap(record.data?.sounds)) continue;
346
+ const address = noteAddressOf(record);
347
+ if (address) out.push([address, record]);
348
+ }
349
+ return out;
350
+ }
351
+
331
352
  /** A language's `data.sounds`, or an empty map. */
332
353
  const soundsOf = (data) => (isMap(data.sounds) ? data.sounds : {});
333
354
 
@@ -494,8 +515,8 @@ export function scopeRows(records) {
494
515
  }
495
516
 
496
517
  /**
497
- * The `letters` rows: one per `data.sounds.letters` entry, `letters` and
498
- * `tongue` lists.
518
+ * The `letters` rows: one per `data.sounds.letters` entry of a language note
519
+ * or a spelling guide, `letters` and `tongue` lists.
499
520
  *
500
521
  * @param {Iterable<Record<string, any>>} records - Index records.
501
522
  * @returns {object[]}
@@ -503,6 +524,7 @@ export function scopeRows(records) {
503
524
  export function letterRows(records) {
504
525
  return listRows(records, (data) => soundsOf(data).letters, LETTER_KEYS, {
505
526
  lists: ["letters", "tongue"],
527
+ sources: soundSourcesOf,
506
528
  });
507
529
  }
508
530
 
@@ -875,24 +897,28 @@ export function joinRows(records) {
875
897
  }
876
898
 
877
899
  /**
878
- * The `marks` rows: one per `data.sounds.marks` entry.
900
+ * The `marks` rows: one per `data.sounds.marks` entry of a language note or a
901
+ * spelling guide.
879
902
  *
880
903
  * @param {Iterable<Record<string, any>>} records - Index records.
881
904
  * @returns {object[]}
882
905
  */
883
906
  export function markRows(records) {
884
- return listRows(records, (data) => soundsOf(data).marks, MARK_KEYS, { lists: ["letters"] });
907
+ return listRows(records, (data) => soundsOf(data).marks, MARK_KEYS, {
908
+ lists: ["letters"],
909
+ sources: soundSourcesOf,
910
+ });
885
911
  }
886
912
 
887
913
  /**
888
- * The `skeleton` rows: one per `data.sounds.skeleton` entry, `second` a list.
914
+ * The `skeleton` rows: one per `data.sounds.skeleton` entry, `second` and `third` lists.
889
915
  *
890
916
  * @param {Iterable<Record<string, any>>} records - Index records.
891
917
  * @returns {object[]}
892
918
  */
893
919
  export function skeletonRows(records) {
894
920
  return listRows(records, (data) => soundsOf(data).skeleton, SKELETON_KEYS, {
895
- lists: ["second"],
921
+ lists: ["second", "third"],
896
922
  });
897
923
  }
898
924
 
@@ -130,7 +130,7 @@ export const CLUSTER_USES = Object.freeze(["common", "sparing"]);
130
130
  export const STRESS_TWO = Object.freeze(["first", "last"]);
131
131
 
132
132
  /** The syllable a word of three or more stresses, counted from its end. */
133
- export const STRESS_LONGER = Object.freeze(["antepenultimate", "penultimate", "final"]);
133
+ export const STRESS_LONGER = Object.freeze(["first", "antepenultimate", "penultimate", "final"]);
134
134
 
135
135
  /**
136
136
  * The diacritics that mark a stress the rule would not choose, with the
@@ -152,7 +152,7 @@ export const JOIN_MEETINGS = Object.freeze(["C+C", "V+C", "V+V"]);
152
152
  export const JOIN_RULES = Object.freeze(["merge", "insert", "drop", "double"]);
153
153
 
154
154
  /** What a written mark says of its letter. */
155
- export const MARK_KINDS = Object.freeze(["stress", "length"]);
155
+ export const MARK_KINDS = Object.freeze(["stress", "length", "apart"]);
156
156
 
157
157
  /** How many of a mark a word may carry: `one` at most, or `any` number. */
158
158
  export const MARK_LIMITS = Object.freeze(["one", "any"]);
@@ -377,13 +377,20 @@ export const LETTER_FIELDS = Object.freeze([
377
377
  shape: "a string, such as `lips` or `tongue-tip`",
378
378
  describe: "Where in the mouth they are made.",
379
379
  }),
380
+ Object.freeze({
381
+ name: "ipa",
382
+ kind: "string",
383
+ shape: "a string in the International Phonetic Alphabet, such as `ʃ`",
384
+ describe: "The sounds they write, in the International Phonetic Alphabet.",
385
+ }),
380
386
  Object.freeze({
381
387
  name: "tongue",
382
388
  kind: "string-or-list",
383
- shape: "a tag, or a list of them",
389
+ shape: "a tag or a language note's Address, or a list of them",
384
390
  entries: STRINGS,
385
391
  describe:
386
- "The tongues that write them, as tags `data.tongues` declares; every one where absent.",
392
+ "The tongues that write them, each a tag `data.tongues` declares or the Address of a " +
393
+ "language note; every one where absent. A `doc` note's rows name language notes only.",
387
394
  }),
388
395
  ]);
389
396
 
@@ -529,6 +536,12 @@ export const FINAL_FIELDS = Object.freeze([
529
536
  shape: "a string",
530
537
  describe: "What it sounds like.",
531
538
  }),
539
+ Object.freeze({
540
+ name: "ipa",
541
+ kind: "string",
542
+ shape: "a string in the International Phonetic Alphabet, such as `ʃ`",
543
+ describe: "Its sound, in the International Phonetic Alphabet.",
544
+ }),
532
545
  ]);
533
546
 
534
547
  /**
@@ -718,7 +731,8 @@ export const STRESS_FIELDS = Object.freeze([
718
731
  required: true,
719
732
  allowed: STRESS_LONGER,
720
733
  shape: `one of ${STRESS_LONGER.join(", ")}`,
721
- describe: "The stressed syllable of a word of three or more, counted from its end.",
734
+ describe:
735
+ "The stressed syllable of a word of three or more: `first`, or counted from its end.",
722
736
  }),
723
737
  Object.freeze({
724
738
  name: "mark",
@@ -817,6 +831,12 @@ export const SKELETON_FIELDS = Object.freeze([
817
831
  required: true,
818
832
  describe: "The second consonants of a root the row holds for; `any` for every other.",
819
833
  }),
834
+ Object.freeze({
835
+ name: "third",
836
+ ...SOUNDS_OR_ANY,
837
+ describe:
838
+ "The third consonants of a root the row holds for; every third consonant where absent.",
839
+ }),
820
840
  Object.freeze({
821
841
  name: "between",
822
842
  kind: "string",
@@ -914,10 +934,10 @@ export const SOUND_FIELDS = Object.freeze([
914
934
  Object.freeze({
915
935
  name: "letters",
916
936
  kind: "list",
917
- shape: "list of `{ letters, kind, like, tongue? }`",
937
+ shape: "list of `{ letters, kind, like?, place?, ipa?, tongue? }`",
918
938
  entries: Object.freeze({
919
939
  kind: "map",
920
- shape: "a map — `{ letters, kind, like, tongue? }`",
940
+ shape: "a map — `{ letters, kind, like?, place?, ipa?, tongue? }`",
921
941
  fields: LETTER_FIELDS,
922
942
  }),
923
943
  describe: "The letters the language writes, and the sounds they stand for.",
@@ -950,10 +970,10 @@ export const SOUND_FIELDS = Object.freeze([
950
970
  Object.freeze({
951
971
  name: "finals",
952
972
  kind: "list",
953
- shape: "list of `{ final, like }`",
973
+ shape: "list of `{ final, like, ipa? }`",
954
974
  entries: Object.freeze({
955
975
  kind: "map",
956
- shape: "a map — `{ final, like }`",
976
+ shape: "a map — `{ final, like, ipa? }`",
957
977
  fields: FINAL_FIELDS,
958
978
  }),
959
979
  describe: "The syllable finals, for a language whose syllables are parsed by them.",
@@ -1047,6 +1067,16 @@ export const SOUND_FIELDS = Object.freeze([
1047
1067
  }),
1048
1068
  ]);
1049
1069
 
1070
+ /**
1071
+ * The keys of a spelling guide's `data.sounds`: a `doc` note stating the
1072
+ * letters and marks every tongue of a setting is written by.
1073
+ *
1074
+ * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
1075
+ */
1076
+ export const GUIDE_SOUND_FIELDS = Object.freeze(
1077
+ SOUND_FIELDS.filter((field) => field.name === "letters" || field.name === "marks"),
1078
+ );
1079
+
1050
1080
  /**
1051
1081
  * The keys of one `data.soundChanges` entry.
1052
1082
  *
@@ -55,6 +55,7 @@ import { positionOfFrontmatterPath, positionOfYamlPath } from "./diagnostics.mjs
55
55
  import { isNoteRecord } from "./index-records.mjs";
56
56
  import { nearest } from "./near-miss.mjs";
57
57
  import { WRITTEN_ENDINGS } from "./language-terms.mjs";
58
+ import { fromWrittenIn } from "./written-in.mjs";
58
59
  import { writingsOf } from "./logographic.mjs";
59
60
  import { FONTS_PACKAGE } from "./script-fonts.mjs";
60
61
  import { GENERIC_FAMILIES, applyMap } from "./text-maps.mjs";
@@ -386,14 +387,37 @@ export function fontProblem(map, font) {
386
387
  }
387
388
 
388
389
  /**
389
- * The mapping a command uses, or what is wrong with what it was given. A map
390
- * listing one face defaults to it; one listing more needs `font`.
390
+ * The mapping a command uses, or what is wrong with what it was given. The
391
+ * language's `data.writtenIn` fills the map and the face it leaves out, as it
392
+ * fills a run's; after that, a map listing one face defaults to it, and one
393
+ * listing more needs `font`.
391
394
  *
392
395
  * @param {object} ctx - The run context.
393
- * @param {{map: string, font?: string, lang?: string}} keys - What was asked for.
396
+ * @param {{map?: string, font?: string, lang?: string}} keys - What was asked for.
394
397
  * @returns {{mapping: Mapping}|{problem: string, about: "map"|"lang"|"font"}} The mapping or the problem.
395
398
  */
396
- function commandMapping(ctx, { map, font, lang }) {
399
+ function commandMapping(ctx, keys) {
400
+ const { lang } = keys;
401
+ const filled = fromWrittenIn(keys, ctx?.writtenIn, ctx?.maps);
402
+ const map = keys.map ?? filled.map;
403
+ const font = keys.font ?? filled.font;
404
+ if (map === undefined) {
405
+ if (lang !== undefined && !Object.hasOwn(ctx?.languages ?? {}, lang)) {
406
+ const near = nearest(lang, Object.keys(ctx?.languages ?? {}));
407
+ return {
408
+ about: "lang",
409
+ problem:
410
+ `\`${lang}\` is not a declared language (\`languages\` in ${CONFIG})` +
411
+ (near ? `; did you mean \`${near}\`?` : ""),
412
+ };
413
+ }
414
+ return {
415
+ about: "map",
416
+ problem:
417
+ `\`${lang}\` names no script: its note states no \`data.writtenIn\`, so name a ` +
418
+ "map with `--map`",
419
+ };
420
+ }
397
421
  const textMap = ctx?.maps?.get(map);
398
422
  if (!textMap) {
399
423
  const names = [...(ctx?.maps?.keys() ?? [])];
@@ -543,8 +567,9 @@ export function transliterate(ctx, keys, text) {
543
567
  * writing, the signs a run writes directly for that sense.
544
568
  *
545
569
  * @param {object} ctx - The run context.
546
- * @param {{lang: string, map: string, font?: string, derived?: boolean}} keys -
547
- * The language, the map, the face, and whether to list derived forms.
570
+ * @param {{lang: string, map?: string, font?: string, derived?: boolean}} keys -
571
+ * The language, the map, the face, and whether to list derived forms. A map
572
+ * or a face left out is the one the language's `data.writtenIn` names.
548
573
  * @returns {{entries: Array<{form: string, native: string, exception?: boolean, source?: "own"|"parts"|"none", ambiguous?: true, class: string|null, gloss: string|null, from?: string}>}|{problem: string, about: string}}
549
574
  * The words in `data.words` order, then any derived forms, or the problem.
550
575
  */
@@ -139,8 +139,8 @@ import {
139
139
  checkLanguageWords,
140
140
  } from "./language-words.mjs";
141
141
  import { checkGlossary } from "./glossary-entries.mjs";
142
- import { LANGUAGE_DATA_FIELDS } from "./language-terms.mjs";
143
- import { checkLanguageData } from "./language-data.mjs";
142
+ import { GUIDE_SOUND_FIELDS, LANGUAGE_DATA_FIELDS } from "./language-terms.mjs";
143
+ import { checkGuideSounds, checkLanguageData } from "./language-data.mjs";
144
144
  import { checkAffiliationReach } from "./culture-reach.mjs";
145
145
  import { checkLanguageNames, requiredGivenGenders } from "./language-names.mjs";
146
146
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
@@ -163,7 +163,12 @@ import { checkMapField } from "./text-maps.mjs";
163
163
  import { checkConceptList, checkCoreFlag } from "./core-vocabulary.mjs";
164
164
  import { METRE_KINDS, checkMetresField } from "./metre-declaration.mjs";
165
165
  import { checkLanguageEmphasis, checkScriptField } from "./script-emphasis.mjs";
166
- import { EMPHASIS_CONVENTIONS, LANGUAGE_EMPHASES } from "./script-emphasis-terms.mjs";
166
+ import { WRITTEN_IN_KEY, checkWrittenIn } from "./written-in.mjs";
167
+ import {
168
+ EMPHASIS_CONVENTIONS,
169
+ LANGUAGE_EMPHASES,
170
+ SCRIPT_DIRECTIONS,
171
+ } from "./script-emphasis-terms.mjs";
167
172
 
168
173
  /**
169
174
  * One `data:` key a note type may carry.
@@ -2182,10 +2187,36 @@ export const NOTE_VOCABULARY = Object.freeze({
2182
2187
  "and `words` marks it with a word, so either is a finding. Only a skill of " +
2183
2188
  "`subType: language` states it.",
2184
2189
  },
2190
+ {
2191
+ name: WRITTEN_IN_KEY,
2192
+ kind: "map",
2193
+ shape: "a map — `{ map, font }`",
2194
+ check: checkWrittenIn,
2195
+ fields: Object.freeze([
2196
+ {
2197
+ name: "map",
2198
+ kind: "string",
2199
+ shape: "a mapping note's `data.map.name`",
2200
+ required: true,
2201
+ describe: "The map the language's text is written by.",
2202
+ },
2203
+ {
2204
+ name: "font",
2205
+ kind: "string",
2206
+ shape: "an installed face's slug",
2207
+ required: true,
2208
+ describe: "The face the text is set in, one the map is drawn in.",
2209
+ },
2210
+ ]),
2211
+ describe:
2212
+ "The script the language is written in, as the map and the face a run marked " +
2213
+ "with its name alone takes; a run's own `map=` or `font=` overrides it. Only a " +
2214
+ "skill of `subType: language` states it.",
2215
+ },
2185
2216
  {
2186
2217
  name: "script",
2187
2218
  kind: "map",
2188
- shape: "a map — `{ fonts, emphasis, strongEmphasis? }`",
2219
+ shape: "a map — `{ fonts, emphasis, strongEmphasis?, direction? }`",
2189
2220
  check: checkScriptField,
2190
2221
  fields: Object.freeze([
2191
2222
  {
@@ -2220,10 +2251,20 @@ export const NOTE_VOCABULARY = Object.freeze({
2220
2251
  allowed: EMPHASIS_CONVENTIONS,
2221
2252
  describe: "The convention `**…**` takes instead of `emphasis`'s.",
2222
2253
  },
2254
+ {
2255
+ name: "direction",
2256
+ kind: "string",
2257
+ shape: "`vertical-rl`",
2258
+ allowed: SCRIPT_DIRECTIONS,
2259
+ describe:
2260
+ "How the script is written: `vertical-rl` is columns, top to bottom, " +
2261
+ "read from the right, which a `scroll` fence sets. A script stating " +
2262
+ "none is written in lines.",
2263
+ },
2223
2264
  ]),
2224
2265
  describe:
2225
- "A script note's faces and how it marks emphasis. Only a skill of " +
2226
- "`subType: script` states it.",
2266
+ "A script note's faces, how it marks emphasis and the direction it is " +
2267
+ "written in. Only a skill of `subType: script` states it.",
2227
2268
  },
2228
2269
  ]),
2229
2270
  }),
@@ -2323,6 +2364,17 @@ export const NOTE_VOCABULARY = Object.freeze({
2323
2364
  "The culture a setting guide introduces, as a culture lore note. " +
2324
2365
  "Required on `settingguide`; no other subType states it.",
2325
2366
  },
2367
+ {
2368
+ name: "sounds",
2369
+ kind: "map",
2370
+ shape: "a map — `{ letters?, marks? }`",
2371
+ fields: GUIDE_SOUND_FIELDS,
2372
+ check: checkGuideSounds,
2373
+ describe:
2374
+ "A spelling guide's letters and the marks written on them, for every " +
2375
+ "tongue of the setting: each letter row's `tongue` names the language notes " +
2376
+ "that write it.",
2377
+ },
2326
2378
  {
2327
2379
  name: "map",
2328
2380
  kind: "map",