@heroiclands/package-build 23.13.0 → 24.0.1

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 (236) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +2 -1
  3. package/assets/preview/gitea.ini +12 -7
  4. package/assets/styles/content-base.css +150 -1
  5. package/bin/content-commands.mjs +356 -210
  6. package/bin/package-build.mjs +3 -2
  7. package/ci/check-banners.mjs +7 -7
  8. package/config.mjs +21 -6
  9. package/content-config.mjs +199 -80
  10. package/docs/api.md +93 -72
  11. package/docs/authoring/links-and-markup.md +316 -78
  12. package/docs/commands.md +248 -80
  13. package/docs/configuration.md +210 -102
  14. package/docs/content-format.md +2 -2
  15. package/docs/diagnostics.md +2 -2
  16. package/docs/getting-started.md +1 -1
  17. package/docs/guides/icons.md +1 -1
  18. package/docs/guides/module-project.md +5 -8
  19. package/docs/guides/release-and-deploy.md +2 -2
  20. package/docs/guides/site.md +155 -23
  21. package/docs/guides/system-project.md +1 -1
  22. package/docs/guides/writing-in-invented-scripts.md +1247 -0
  23. package/docs/index.md +1 -0
  24. package/docs/project-setup.md +28 -30
  25. package/docs/reference/format-details.md +589 -308
  26. package/docs/reference/note-types.md +40 -10
  27. package/docs/reference/theme-pages.md +127 -108
  28. package/docs/source-preview.md +15 -1
  29. package/engine/asset-shipping.mjs +22 -10
  30. package/engine/base-compiler.mjs +62 -104
  31. package/engine/changelog-lint.mjs +1 -1
  32. package/engine/content-address.mjs +1 -1
  33. package/engine/content-alerts.mjs +8 -4
  34. package/engine/content-blocks.mjs +26 -11
  35. package/engine/content-embeds.mjs +1 -1
  36. package/engine/content-expand.mjs +376 -0
  37. package/engine/content-figures.mjs +13 -6
  38. package/engine/content-format.yaml +8 -0
  39. package/engine/content-glyphs.mjs +213 -0
  40. package/engine/content-images.mjs +6 -7
  41. package/engine/content-links.mjs +3 -3
  42. package/engine/content-lint.mjs +1 -1
  43. package/engine/content-render.mjs +453 -0
  44. package/engine/content-scripts.mjs +326 -27
  45. package/engine/content-spans.mjs +4 -1
  46. package/engine/content-styles.mjs +91 -0
  47. package/engine/content-symbols.mjs +186 -0
  48. package/engine/content-verse.mjs +6 -18
  49. package/engine/core-report.mjs +169 -0
  50. package/engine/core-vocabulary.mjs +208 -0
  51. package/engine/dictionary-html.mjs +119 -0
  52. package/engine/field-reference.mjs +1 -1
  53. package/engine/frontmatter-lint.mjs +1 -1
  54. package/engine/heading-attributes.mjs +89 -0
  55. package/engine/helpers.mjs +61 -101
  56. package/engine/homepage.mjs +17 -29
  57. package/engine/html-minify.mjs +70 -0
  58. package/engine/index.mjs +8 -5
  59. package/engine/language-rows.mjs +8 -2
  60. package/engine/language-terms.mjs +115 -14
  61. package/engine/language-words.mjs +12 -0
  62. package/engine/logographic.mjs +255 -0
  63. package/engine/map-lexicon.mjs +221 -25
  64. package/engine/map-places.mjs +5 -4
  65. package/engine/markdown-expressions.mjs +0 -2
  66. package/engine/markdownlint-run.mjs +9 -3
  67. package/engine/markup-validation.mjs +21 -0
  68. package/engine/metre-declaration.mjs +134 -0
  69. package/engine/note-matter.mjs +2 -2
  70. package/engine/note-vocabulary.mjs +187 -3
  71. package/engine/pdf-build.mjs +68 -133
  72. package/engine/pdf-render.mjs +102 -44
  73. package/engine/post-fields.mjs +157 -0
  74. package/engine/preview-index.mjs +130 -0
  75. package/engine/preview-source.mjs +450 -0
  76. package/engine/project-init.mjs +5 -12
  77. package/engine/prose-config.mjs +3 -3
  78. package/engine/run-words.mjs +3 -2
  79. package/engine/script-emphasis-terms.mjs +38 -0
  80. package/engine/script-emphasis.mjs +295 -0
  81. package/engine/script-font-delivery.mjs +4 -4
  82. package/engine/site-build.mjs +265 -313
  83. package/engine/site-config.mjs +26 -238
  84. package/engine/site-files.mjs +175 -0
  85. package/engine/site-index.mjs +9 -4
  86. package/engine/site-preview.mjs +50 -523
  87. package/engine/site-reach.mjs +213 -0
  88. package/engine/site-render.mjs +668 -0
  89. package/engine/site-root.mjs +90 -39
  90. package/engine/site-search.mjs +161 -0
  91. package/engine/site-serve.mjs +274 -0
  92. package/engine/site-theme-banner.mjs +154 -0
  93. package/engine/site-theme-urls.mjs +125 -0
  94. package/engine/site-theme.mjs +939 -0
  95. package/engine/sql-tables.mjs +9 -6
  96. package/engine/text-maps.mjs +57 -10
  97. package/engine/typographer.mjs +248 -0
  98. package/engine/verse-metre.mjs +357 -0
  99. package/engine/wikilink-syntax.mjs +29 -47
  100. package/engine/wikilinks.mjs +758 -279
  101. package/engine/word-forms.mjs +39 -24
  102. package/engine/word-rows.mjs +12 -2
  103. package/engine/word-terms.mjs +29 -4
  104. package/manifest.mjs +3 -4
  105. package/package.json +10 -7
  106. package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
  107. package/site-theme/layouts/base.hbs +31 -0
  108. package/site-theme/layouts/blog-list.hbs +30 -0
  109. package/site-theme/layouts/blog-post.hbs +40 -0
  110. package/site-theme/layouts/home.hbs +35 -0
  111. package/site-theme/layouts/list.hbs +20 -0
  112. package/site-theme/layouts/not-found.hbs +31 -0
  113. package/site-theme/layouts/page.hbs +7 -0
  114. package/site-theme/partials/copy-buttons.hbs +37 -0
  115. package/site-theme/partials/draft-notice.hbs +5 -0
  116. package/site-theme/partials/exturl.hbs +2 -0
  117. package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
  118. package/site-theme/partials/header.hbs +44 -0
  119. package/site-theme/partials/hero-banner.hbs +11 -0
  120. package/site-theme/partials/infobox-entry.hbs +2 -0
  121. package/site-theme/partials/infobox-section.hbs +31 -0
  122. package/site-theme/partials/infobox-value.hbs +2 -0
  123. package/site-theme/partials/infobox.hbs +18 -0
  124. package/site-theme/partials/list-members.hbs +9 -0
  125. package/site-theme/partials/nav-links.hbs +5 -0
  126. package/site-theme/partials/page-body.hbs +48 -0
  127. package/site-theme/partials/page-list.hbs +14 -0
  128. package/site-theme/partials/related.hbs +24 -0
  129. package/site-theme/partials/search-meta.hbs +6 -0
  130. package/site-theme/partials/search-names.hbs +2 -0
  131. package/site-theme/partials/search.hbs +52 -0
  132. package/site-theme/partials/tags.hbs +4 -0
  133. package/site-theme/partials/toc.hbs +7 -0
  134. package/{hugo-theme → site-theme}/static/css/style.css +14 -0
  135. package/stage.mjs +1 -2
  136. package/types/config.d.mts +8 -4
  137. package/types/content-config.d.mts +15 -15
  138. package/types/engine/asset-shipping.d.mts +5 -2
  139. package/types/engine/base-compiler.d.mts +20 -2
  140. package/types/engine/content-address.d.mts +1 -1
  141. package/types/engine/content-alerts.d.mts +8 -2
  142. package/types/engine/content-blocks.d.mts +11 -4
  143. package/types/engine/content-expand.d.mts +288 -0
  144. package/types/engine/content-figures.d.mts +11 -3
  145. package/types/engine/content-glyphs.d.mts +85 -0
  146. package/types/engine/content-images.d.mts +6 -7
  147. package/types/engine/content-links.d.mts +1 -1
  148. package/types/engine/content-render.d.mts +199 -0
  149. package/types/engine/content-scripts.d.mts +62 -0
  150. package/types/engine/content-spans.d.mts +1 -1
  151. package/types/engine/content-styles.d.mts +33 -0
  152. package/types/engine/content-symbols.d.mts +35 -0
  153. package/types/engine/core-report.d.mts +45 -0
  154. package/types/engine/core-vocabulary.d.mts +53 -0
  155. package/types/engine/dictionary-html.d.mts +8 -0
  156. package/types/engine/field-reference.d.mts +1 -1
  157. package/types/engine/heading-attributes.d.mts +20 -0
  158. package/types/engine/helpers.d.mts +3 -10
  159. package/types/engine/homepage.d.mts +11 -24
  160. package/types/engine/html-minify.d.mts +9 -0
  161. package/types/engine/index.d.mts +2 -1
  162. package/types/engine/language-terms.d.mts +24 -2
  163. package/types/engine/logographic.d.mts +45 -0
  164. package/types/engine/map-lexicon.d.mts +48 -8
  165. package/types/engine/map-places.d.mts +8 -0
  166. package/types/engine/markdown-expressions.d.mts +0 -2
  167. package/types/engine/metre-declaration.d.mts +39 -0
  168. package/types/engine/pdf-render.d.mts +6 -2
  169. package/types/engine/post-fields.d.mts +44 -0
  170. package/types/engine/preview-index.d.mts +72 -0
  171. package/types/engine/preview-source.d.mts +51 -0
  172. package/types/engine/script-emphasis-terms.d.mts +21 -0
  173. package/types/engine/script-emphasis.d.mts +84 -0
  174. package/types/engine/script-font-delivery.d.mts +4 -4
  175. package/types/engine/site-build.d.mts +71 -113
  176. package/types/engine/site-config.d.mts +14 -134
  177. package/types/engine/site-files.d.mts +73 -0
  178. package/types/engine/site-index.d.mts +11 -3
  179. package/types/engine/site-preview.d.mts +15 -19
  180. package/types/engine/site-reach.d.mts +76 -0
  181. package/types/engine/site-render.d.mts +173 -0
  182. package/types/engine/site-root.d.mts +14 -4
  183. package/types/engine/site-search.d.mts +54 -0
  184. package/types/engine/site-serve.d.mts +44 -0
  185. package/types/engine/site-theme-banner.d.mts +51 -0
  186. package/types/engine/site-theme-urls.d.mts +60 -0
  187. package/types/engine/site-theme.d.mts +127 -0
  188. package/types/engine/text-maps.d.mts +17 -3
  189. package/types/engine/typographer.d.mts +11 -0
  190. package/types/engine/verse-metre.d.mts +131 -0
  191. package/types/engine/wikilink-syntax.d.mts +30 -48
  192. package/types/engine/wikilinks.d.mts +330 -14
  193. package/types/engine/word-forms.d.mts +3 -2
  194. package/types/manifest.d.mts +3 -4
  195. package/types/stage.d.mts +1 -2
  196. package/types/vendor/markdownlint.d.mts +8 -0
  197. package/vendor/markdownlint.mjs +20388 -0
  198. package/engine/web-wikilinks.mjs +0 -594
  199. package/hugo-theme/layouts/404.html +0 -85
  200. package/hugo-theme/layouts/_default/baseof.html +0 -67
  201. package/hugo-theme/layouts/_default/list.html +0 -139
  202. package/hugo-theme/layouts/_default/single.html +0 -4
  203. package/hugo-theme/layouts/blog/list.html +0 -54
  204. package/hugo-theme/layouts/blog-post/single.html +0 -36
  205. package/hugo-theme/layouts/index.html +0 -75
  206. package/hugo-theme/layouts/partials/banner-url.html +0 -141
  207. package/hugo-theme/layouts/partials/cdn-url.html +0 -36
  208. package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
  209. package/hugo-theme/layouts/partials/draft-notice.html +0 -41
  210. package/hugo-theme/layouts/partials/draft-state.html +0 -26
  211. package/hugo-theme/layouts/partials/header.html +0 -35
  212. package/hugo-theme/layouts/partials/hero-banner.html +0 -85
  213. package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
  214. package/hugo-theme/layouts/partials/infobox/items.html +0 -26
  215. package/hugo-theme/layouts/partials/infobox/section.html +0 -56
  216. package/hugo-theme/layouts/partials/infobox/value.html +0 -43
  217. package/hugo-theme/layouts/partials/infobox.html +0 -57
  218. package/hugo-theme/layouts/partials/page-body.html +0 -152
  219. package/hugo-theme/layouts/partials/related.html +0 -138
  220. package/hugo-theme/layouts/partials/search.html +0 -84
  221. package/hugo-theme/layouts/partials/site-url.html +0 -32
  222. package/hugo-theme/layouts/partials/tags.html +0 -30
  223. package/hugo-theme/layouts/partials/toc.html +0 -28
  224. package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
  225. package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
  226. package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
  227. package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
  228. package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
  229. package/hugo-theme/theme.toml +0 -11
  230. package/types/engine/web-wikilinks.d.mts +0 -107
  231. /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
  232. /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
  233. /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
  234. /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
  235. /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
  236. /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
@@ -37,6 +37,9 @@
37
37
  * over one with none.
38
38
  * - **Derived forms take the rows**: a plural or a verb ending of an exception
39
39
  * word is spelled by the rows, and only the word as cited is looked up.
40
+ * - **A logographic map** spells no word by its rows: every word is written
41
+ * from the lexicon, by its own writing or its parts' (see
42
+ * {@link module:engine/logographic}), and a word with neither is a finding.
40
43
  *
41
44
  * {@link mappingFor} is the one path from a map, a face and a language to the
42
45
  * function that rewrites text: the build's runs, `package-build transliterate`
@@ -51,6 +54,8 @@ import { parseAddress, renderAddress } from "./address.mjs";
51
54
  import { positionOfFrontmatterPath, positionOfYamlPath } from "./diagnostics.mjs";
52
55
  import { isNoteRecord } from "./index-records.mjs";
53
56
  import { nearest } from "./near-miss.mjs";
57
+ import { WRITTEN_ENDINGS } from "./language-terms.mjs";
58
+ import { writingsOf } from "./logographic.mjs";
54
59
  import { FONTS_PACKAGE } from "./script-fonts.mjs";
55
60
  import { GENERIC_FAMILIES, applyMap } from "./text-maps.mjs";
56
61
  import { isLanguageRecord, noteAddressOf } from "./word-rows.mjs";
@@ -107,6 +112,8 @@ function said(names) {
107
112
  * @property {ReadonlyArray<{form: string, from: LexiconWord}>} derived - The
108
113
  * forms the language's rules derive from its words, folded, as the mapped-run
109
114
  * word check accepts them (see {@link module:engine/word-forms.derivedForms}).
115
+ * @property {Record<string, any>} data - The language note's `data`, which a
116
+ * logographic map writes words from.
110
117
  */
111
118
 
112
119
  /**
@@ -161,6 +168,7 @@ function lexiconOf(language, data) {
161
168
  words: Object.freeze(words),
162
169
  byForm,
163
170
  derived: Object.freeze(derived),
171
+ data: isMap(data) ? data : {},
164
172
  });
165
173
  }
166
174
 
@@ -330,7 +338,10 @@ export function nativeLookup(lexicon, map, font) {
330
338
  * @property {import("./text-maps.mjs").TextMap} map - The map.
331
339
  * @property {string|undefined} font - The face, whose own rows come first.
332
340
  * @property {((word: string) => string|undefined)|undefined} native - The
333
- * language's native forms under the map in the face.
341
+ * language's native forms under the map in the face, for a map that spells
342
+ * by its rows.
343
+ * @property {((word: string) => import("./logographic.mjs").Writing)|undefined} writing -
344
+ * Each word's writing, for a logographic map.
334
345
  */
335
346
 
336
347
  /**
@@ -346,7 +357,20 @@ export function mappingFor(ctx, { map, font, lang }) {
346
357
  const textMap = ctx?.maps?.get(map);
347
358
  if (!textMap) return undefined;
348
359
  const lexicon = lang === undefined ? undefined : ctx.lexicons?.get(lang);
349
- return { map: textMap, font, native: nativeLookup(lexicon, textMap.name, font) };
360
+ if (!textMap.logographic)
361
+ return {
362
+ map: textMap,
363
+ font,
364
+ native: nativeLookup(lexicon, textMap.name, font),
365
+ writing: undefined,
366
+ };
367
+ const writings = lexicon ? writingsOf(lexicon.data, textMap.name, font) : undefined;
368
+ return {
369
+ map: textMap,
370
+ font,
371
+ native: undefined,
372
+ writing: writings ? writings.word : () => ({ missing: true, noLexicon: true }),
373
+ };
350
374
  }
351
375
 
352
376
  /**
@@ -424,6 +448,52 @@ export function uncapitalisedMessage(word, lower, lang, map) {
424
448
  );
425
449
  }
426
450
 
451
+ /**
452
+ * What is wrong with a word a logographic map gives no writing.
453
+ *
454
+ * @param {string} word - The word as written.
455
+ * @param {object} unwritten - Why it has none, as
456
+ * {@link module:engine/logographic.writingsOf} says.
457
+ * @param {string|undefined} lang - The run's language.
458
+ * @param {string} map - The map's name.
459
+ * @returns {string} The message.
460
+ */
461
+ export function unwrittenMessage(word, unwritten, lang, map) {
462
+ const why = /** @type {{noLexicon?: true, ambiguous?: {form: string, glosses: string[]}}} */ (
463
+ unwritten
464
+ );
465
+ if (why.noLexicon)
466
+ return lang === undefined ?
467
+ `\`${word}\` has no \`${map}\` writing: the map \`${map}\` is logographic, and the run ` +
468
+ "names no `lang=` whose lexicon writes it; mark the run with its language, or " +
469
+ "write the word directly in signs"
470
+ : `\`${word}\` has no \`${map}\` writing: the map \`${map}\` is logographic, and \`${lang}\` ` +
471
+ `has no lexicon (\`languages.${lang}.note\`) to write it from; write the word ` +
472
+ "directly in signs";
473
+ if (why.ambiguous) {
474
+ const { form, glosses } = why.ambiguous;
475
+ const quoted = glosses.map((gloss) => `“${gloss}”`);
476
+ const listed =
477
+ quoted.length < 2 ?
478
+ quoted.join("")
479
+ : `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
480
+ const stands =
481
+ `stands for ${glosses.length} words of the \`${lang}\` lexicon that the map ` +
482
+ `\`${map}\` writes differently, ${listed}`;
483
+ return (
484
+ (form === word ?
485
+ `\`${word}\` ${stands}`
486
+ : `\`${word}\` is written from its part \`${form}\`, which ${stands}`) +
487
+ "; write the word directly in signs, choosing by its sense in " +
488
+ `\`package-build lexicon ${lang} --map ${map}\``
489
+ );
490
+ }
491
+ return (
492
+ `\`${word}\` has no \`${map}\` writing in the \`${lang}\` lexicon, and its parts do not ` +
493
+ "give one; state one, or write the word directly in signs"
494
+ );
495
+ }
496
+
427
497
  /**
428
498
  * Map a passage exactly as the build maps a run with the same keys.
429
499
  *
@@ -436,8 +506,14 @@ export function uncapitalisedMessage(word, lower, lang, map) {
436
506
  export function transliterate(ctx, keys, text) {
437
507
  const found = commandMapping(ctx, keys);
438
508
  if ("problem" in found) return { problem: found.problem, about: found.about };
439
- const { map, font, native } = found.mapping;
440
- const mapped = applyMap(map, text, { font, native });
509
+ const { map, font, native, writing } = found.mapping;
510
+ const mapped = applyMap(map, text, { font, native, writing });
511
+ const unwritten = mapped.pieces.find((one) => one.unwritten !== undefined);
512
+ if (unwritten)
513
+ return {
514
+ about: "word",
515
+ problem: unwrittenMessage(unwritten.from, unwritten.unwritten, keys.lang, map.name),
516
+ };
441
517
  const piece = mapped.pieces.find((one) => one.uncapitalised !== undefined);
442
518
  if (piece)
443
519
  return {
@@ -459,10 +535,17 @@ export function transliterate(ctx, keys, text) {
459
535
  * each case folded and naming the word it comes from in `from`, so the list
460
536
  * is what the mapped-run word check accepts of the lexicon.
461
537
  *
538
+ * Under a logographic map an entry carries `source` in place of `exception`:
539
+ * `own` for a writing it states, `parts` for one its parts give, and `none`,
540
+ * with an empty `native`, where it has none. A word whose form a run cannot
541
+ * write, because the form stands for entries written differently, or a part
542
+ * does, also carries `ambiguous: true`; its `native` is still its own
543
+ * writing, the signs a run writes directly for that sense.
544
+ *
462
545
  * @param {object} ctx - The run context.
463
546
  * @param {{lang: string, map: string, font?: string, derived?: boolean}} keys -
464
547
  * The language, the map, the face, and whether to list derived forms.
465
- * @returns {{entries: Array<{form: string, native: string, exception: boolean, class: string|null, gloss: string|null, from?: string}>}|{problem: string, about: string}}
548
+ * @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}}
466
549
  * The words in `data.words` order, then any derived forms, or the problem.
467
550
  */
468
551
  export function lexiconEntries(ctx, { lang, map, font, derived = false }) {
@@ -477,6 +560,7 @@ export function lexiconEntries(ctx, { lang, map, font, derived = false }) {
477
560
  "in its `note`",
478
561
  };
479
562
  const { map: textMap, font: face, native } = found.mapping;
563
+ if (textMap.logographic) return { entries: writtenEntries(lexicon, textMap, face, derived) };
480
564
  const listed = [
481
565
  ...lexicon.words.map((word) => ({ word, form: word.form, from: undefined })),
482
566
  ...(derived ?
@@ -509,6 +593,51 @@ export function lexiconEntries(ctx, { lang, map, font, derived = false }) {
509
593
  };
510
594
  }
511
595
 
596
+ /**
597
+ * Every word of a lexicon with its writing under a logographic map, as
598
+ * {@link lexiconEntries} lists them.
599
+ *
600
+ * @param {Lexicon} lexicon - The lexicon.
601
+ * @param {import("./text-maps.mjs").TextMap} map - The map.
602
+ * @param {string|undefined} font - The face.
603
+ * @param {boolean} derived - Whether to list derived forms.
604
+ * @returns {Array<Record<string, any>>} The entries.
605
+ */
606
+ function writtenEntries(lexicon, map, font, derived) {
607
+ const writings = writingsOf(lexicon.data, map.name, font);
608
+ // The `data.words` index of each lexicon word, which reads the same entries.
609
+ const indexes = [];
610
+ listOf(lexicon.data.words).forEach((word, index) => {
611
+ if (isMap(word) && typeof word.form === "string") indexes.push(index);
612
+ });
613
+ const shaped = (form, writing, word, from) => {
614
+ // Whether a run of the form, written as the build writes it, meets an
615
+ // ambiguity: another entry of the form, or of a part, written otherwise.
616
+ const run = applyMap(map, form, { font, writing: writings.word }).pieces.some(
617
+ (piece) => piece.unwritten?.ambiguous !== undefined,
618
+ );
619
+ return {
620
+ form,
621
+ native: "text" in writing ? writing.text : "",
622
+ source:
623
+ "text" in writing ? writing.source
624
+ : "ambiguous" in writing ? "parts"
625
+ : "none",
626
+ ...("ambiguous" in writing || run ? { ambiguous: true } : {}),
627
+ class: word.class,
628
+ gloss: word.gloss,
629
+ ...(from === undefined ? {} : { from }),
630
+ };
631
+ };
632
+ const entries = lexicon.words.map((word, at) =>
633
+ shaped(word.form, writings.entry(indexes[at]), word, undefined),
634
+ );
635
+ if (derived)
636
+ for (const one of lexicon.derived)
637
+ entries.push(shaped(one.form, writings.word(one.form), one.from, one.from.form));
638
+ return entries;
639
+ }
640
+
512
641
  /**
513
642
  * Whether a shared row can be reached in a face: no plain row of the face's
514
643
  * own matches at the start of its `from`, which would win there first.
@@ -607,10 +736,16 @@ export function checkMapFonts(note, ctx) {
607
736
  }
608
737
 
609
738
  /**
610
- * Check a language note's native forms against the maps and faces: an unknown
611
- * map, a face the map is not for, a second form for one map and face, a form
612
- * that states what the rows already give, and a code point a face it applies
613
- * in does not draw. The shape of each entry is the data-key check's.
739
+ * Check a language note's native forms against the maps and faces: a word's
740
+ * `native`, an affix's, and the endings' in `data.grammar.native`. Each is
741
+ * checked for an unknown map, a face the map is not for, a second form for one
742
+ * map and face (and one ending), and a code point a face it applies in does
743
+ * not draw. A word's form under a map that spells by its rows is also checked
744
+ * for stating what the rows already give; under a logographic map the rows
745
+ * spell no word, so that check does not apply. An affix's and an ending's
746
+ * writings mean something only under a logographic map, and an ending of a
747
+ * person is written only where `data.grammar.verbs` states `persons`. The
748
+ * shape of each entry is the data-key check's.
614
749
  *
615
750
  * @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
616
751
  * @param {{faces?: ReadonlyMap<string, import("./script-fonts.mjs").Face>, maps?: ReadonlyMap<string, import("./text-maps.mjs").TextMap>}} ctx -
@@ -623,35 +758,47 @@ export function checkLexiconNatives(note, ctx) {
623
758
  const raw = note.raw ?? "";
624
759
  const finding = (keyPath, message, key = false) => ({
625
760
  file: note.file,
626
- ...positionOfFrontmatterPath(raw, ["data", "words", ...keyPath], { key }),
761
+ ...positionOfFrontmatterPath(raw, ["data", ...keyPath], { key }),
627
762
  severity: "error",
628
763
  message,
629
764
  });
630
765
  const maps = ctx?.maps ?? new Map();
631
766
  const faces = ctx?.faces ?? new Map();
632
767
  const findings = [];
633
- listOf(fm.data.words).forEach((word, i) => {
634
- if (!isMap(word) || !Array.isArray(word.native)) return;
635
- const form = typeof word.form === "string" ? word.form : "";
768
+
769
+ /**
770
+ * Check one `native` list.
771
+ *
772
+ * @param {unknown[]} list - The list as authored.
773
+ * @param {Array<string|number>} keyPath - Its path under `data`.
774
+ * @param {object} as - What it belongs to.
775
+ * @param {"word"|"affix"|"ending"} as.kind - A word's, an affix's or the
776
+ * endings' list.
777
+ * @param {string} [as.form] - The word's form, for the rows' check.
778
+ */
779
+ const checkList = (list, keyPath, { kind, form = "" }) => {
780
+ const base = `data.${keyPath.join(".").replace(/\.(\d+)/g, "[$1]")}`;
636
781
  const stated = new Map();
637
- const entries = word.native.map((entry) =>
782
+ const entries = list.map((entry) =>
638
783
  isMap(entry) && typeof entry.map === "string" && typeof entry.text === "string" ?
639
784
  {
640
785
  map: entry.map,
641
786
  font: typeof entry.font === "string" ? entry.font : undefined,
642
787
  text: entry.text,
788
+ ending: typeof entry.ending === "string" ? entry.ending : undefined,
643
789
  }
644
790
  : undefined,
645
791
  );
792
+ const persons = isMap(fm.data.grammar?.verbs?.persons);
646
793
  entries.forEach((entry, j) => {
647
794
  if (!entry) return;
648
- const label = `data.words[${i}].native[${j}]`;
795
+ const label = `${base}[${j}]`;
649
796
  const map = maps.get(entry.map);
650
797
  if (!map) {
651
798
  const near = nearest(entry.map, [...maps.keys()]);
652
799
  findings.push(
653
800
  finding(
654
- [i, "native", j, "map"],
801
+ [...keyPath, j, "map"],
655
802
  `\`${label}.map\` is \`${entry.map}\`, which is not a map: a native form names ` +
656
803
  "the `data.map.name` of a mapping note" +
657
804
  (near ? `; did you mean \`${near}\`?` : ""),
@@ -659,24 +806,51 @@ export function checkLexiconNatives(note, ctx) {
659
806
  );
660
807
  return;
661
808
  }
809
+ if (kind !== "word" && !map.logographic) {
810
+ findings.push(
811
+ finding(
812
+ [...keyPath, j],
813
+ `\`${label}\` names the map \`${map.name}\`, which is not logographic; ` +
814
+ `${kind === "affix" ? "an affix" : "an ending"} is written by a native ` +
815
+ "form only under a logographic map",
816
+ ),
817
+ );
818
+ return;
819
+ }
662
820
  if (entry.font !== undefined && map.fonts.size && !map.fonts.has(entry.font)) {
663
821
  findings.push(
664
822
  finding(
665
- [i, "native", j, "font"],
823
+ [...keyPath, j, "font"],
666
824
  `\`${label}.font\` is \`${entry.font}\`, which is not a font of the map ` +
667
825
  `\`${map.name}\`; it is for ${said([...map.fonts.keys()])}`,
668
826
  ),
669
827
  );
670
828
  return;
671
829
  }
672
- const slot = `${entry.map}\u0000${entry.font ?? ""}`;
830
+ if (
831
+ kind === "ending" &&
832
+ entry.ending !== undefined &&
833
+ entry.ending !== WRITTEN_ENDINGS[0] &&
834
+ WRITTEN_ENDINGS.includes(entry.ending) &&
835
+ !persons
836
+ )
837
+ findings.push(
838
+ finding(
839
+ [...keyPath, j],
840
+ `\`${label}\` writes the ending \`${entry.ending}\`, and \`data.grammar.verbs\` ` +
841
+ "states no `persons`",
842
+ ),
843
+ );
844
+ const slot = `${entry.ending ?? ""}\u0000${entry.map}\u0000${entry.font ?? ""}`;
673
845
  if (stated.has(slot))
674
846
  findings.push(
675
847
  finding(
676
- [i, "native", j],
677
- `\`${label}\` states a second native form for the map \`${map.name}\`` +
848
+ [...keyPath, j],
849
+ `\`${label}\` states a second native form` +
850
+ (kind === "ending" ? ` of the ending \`${entry.ending}\`` : "") +
851
+ ` for the map \`${map.name}\`` +
678
852
  (entry.font === undefined ? "" : ` in the font \`${entry.font}\``) +
679
- `; the first is \`data.words[${i}].native[${stated.get(slot)}]\``,
853
+ `; the first is \`${base}[${stated.get(slot)}]\``,
680
854
  ),
681
855
  );
682
856
  else stated.set(slot, j);
@@ -684,10 +858,14 @@ export function checkLexiconNatives(note, ctx) {
684
858
  entry.font !== undefined ? [entry.font]
685
859
  : map.fonts.size ? [...map.fonts.keys()]
686
860
  : [undefined];
687
- if (fonts.every((font) => applyMap(map, form, { font }).text === entry.text))
861
+ if (
862
+ kind === "word" &&
863
+ !map.logographic &&
864
+ fonts.every((font) => applyMap(map, form, { font }).text === entry.text)
865
+ )
688
866
  findings.push(
689
867
  finding(
690
- [i, "native", j],
868
+ [...keyPath, j],
691
869
  `\`${label}\` states what the rows already give: the map \`${map.name}\` ` +
692
870
  `spells \`${form}\` so ` +
693
871
  (entry.font !== undefined ? `in the font \`${entry.font}\``
@@ -705,7 +883,11 @@ export function checkLexiconNatives(note, ctx) {
705
883
  : [...map.fonts.keys()].filter(
706
884
  (font) =>
707
885
  !entries.some(
708
- (other) => other && other.map === entry.map && other.font === font,
886
+ (other) =>
887
+ other &&
888
+ other.map === entry.map &&
889
+ other.ending === entry.ending &&
890
+ other.font === font,
709
891
  ),
710
892
  );
711
893
  for (const slug of applies) {
@@ -718,7 +900,7 @@ export function checkLexiconNatives(note, ctx) {
718
900
  seen.add(point);
719
901
  findings.push(
720
902
  finding(
721
- [i, "native", j],
903
+ [...keyPath, j],
722
904
  `\`${label}\` holds ${codeName(point)}, which the font \`${slug}\`, ` +
723
905
  `listed in the map \`${map.name}\`, does not draw`,
724
906
  ),
@@ -726,7 +908,21 @@ export function checkLexiconNatives(note, ctx) {
726
908
  }
727
909
  }
728
910
  });
911
+ };
912
+
913
+ listOf(fm.data.words).forEach((word, i) => {
914
+ if (!isMap(word) || !Array.isArray(word.native)) return;
915
+ checkList(word.native, ["words", i, "native"], {
916
+ kind: "word",
917
+ form: typeof word.form === "string" ? word.form : "",
918
+ });
919
+ });
920
+ listOf(fm.data.affixes).forEach((affix, i) => {
921
+ if (isMap(affix) && Array.isArray(affix.native))
922
+ checkList(affix.native, ["affixes", i, "native"], { kind: "affix" });
729
923
  });
924
+ if (isMap(fm.data.grammar) && Array.isArray(fm.data.grammar.native))
925
+ checkList(fm.data.grammar.native, ["grammar", "native"], { kind: "ending" });
730
926
  return findings;
731
927
  }
732
928
 
@@ -131,12 +131,13 @@ function parentShortcode(parent) {
131
131
  }
132
132
 
133
133
  /**
134
- * A `parents` value as a list of shortcodes.
134
+ * A list of places — `parents`, a being's `homes`, a scenario's `locations` —
135
+ * as their shortcodes.
135
136
  *
136
137
  * @param {unknown} value - The authored value, a list or a lone entry.
137
138
  * @returns {string[]} Shortcodes, lower case, empties dropped.
138
139
  */
139
- function parentsOf(value) {
140
+ export function placeShortcodes(value) {
140
141
  const list =
141
142
  Array.isArray(value) ? value
142
143
  : value ? [value]
@@ -198,7 +199,7 @@ export function placesFromRecords(records, { contentBase, base }) {
198
199
  name,
199
200
  type: "place",
200
201
  subType: record.subType === undefined ? undefined : String(record.subType),
201
- parents: parentsOf(data.parents),
202
+ parents: placeShortcodes(data.parents),
202
203
  borders: relationList(data.borders),
203
204
  routes: relationList(data.routes),
204
205
  address: record.address?.canonical,
@@ -251,7 +252,7 @@ function addForeignPlaces(world, foreignIndex) {
251
252
  name: String(entry.name ?? shortcode),
252
253
  type: "place",
253
254
  subType: entry.subType === undefined ? undefined : String(entry.subType),
254
- parents: parentsOf(entry.parents),
255
+ parents: placeShortcodes(entry.parents),
255
256
  borders: relationList(entry.borders),
256
257
  routes: relationList(entry.routes),
257
258
  address: canonical,
@@ -25,8 +25,6 @@ import { noteCalendar } from "./note-calendars.mjs";
25
25
  /**
26
26
  * An inline expression, as an author writes one.
27
27
  *
28
- * Hugo shortcodes begin `{{<` or `{{%` and stay in the source for Hugo.
29
- *
30
28
  * Exported because a renderer that meets one has met a body this pass never ran
31
29
  * over, and has to be able to say so.
32
30
  *
@@ -51,11 +51,16 @@ import {
51
51
  printParseErrorCode,
52
52
  } from "jsonc-parser";
53
53
  import markdownIt from "markdown-it";
54
- import { applyFixes, resolveModule } from "markdownlint";
55
- import { extendConfig, lint, readConfig } from "markdownlint/promise";
56
54
  import { minimatch } from "minimatch";
57
55
  import { LineCounter, parse as parseYaml, parseDocument } from "yaml";
58
56
 
57
+ import {
58
+ applyFixes,
59
+ extendConfig,
60
+ lint,
61
+ readConfig,
62
+ resolveModule,
63
+ } from "../vendor/markdownlint.mjs";
59
64
  import { walkFiles } from "./walk-files.mjs";
60
65
 
61
66
  /** Option-file names, in the order the first existing one is taken. */
@@ -313,7 +318,8 @@ async function validateOptions(file, text, raw, isRoot) {
313
318
  */
314
319
  async function extendRules(file, config) {
315
320
  try {
316
- return await extendConfig(config, file, [...CONFIG_PARSERS]);
321
+ // `extendConfig`, unlike `readConfig`, has no default file system.
322
+ return await extendConfig(config, file, [...CONFIG_PARSERS], fs);
317
323
  } catch (err) {
318
324
  throw new MarkdownConfigError(file, `cannot resolve \`extends\`: ${err.message}`);
319
325
  }
@@ -14,6 +14,26 @@
14
14
  import { scanBlocks } from "./content-blocks.mjs";
15
15
  import { scanAlerts } from "./content-alerts.mjs";
16
16
  import { scanRuns } from "./content-scripts.mjs";
17
+ import { matchAllOutsideCode } from "./code-fences.mjs";
18
+
19
+ /** The opening of a shortcode: `{{<` or `{{%`. */
20
+ const SHORTCODE = /\{\{[<%]/g;
21
+
22
+ /**
23
+ * Locate every shortcode outside code.
24
+ * @param {string} body - Markdown with its frontmatter masked.
25
+ * @returns {Array<{line: number, column: number, message: string}>} One error per shortcode.
26
+ */
27
+ function shortcodeErrors(body) {
28
+ return matchAllOutsideCode(body, SHORTCODE).map((match) => {
29
+ const before = body.slice(0, match.index);
30
+ return {
31
+ line: before.split("\n").length,
32
+ column: match.index - before.lastIndexOf("\n"),
33
+ message: "A shortcode is not supported; write `[x]{.glyph}` or `[signs]{.cartouche}`",
34
+ };
35
+ });
36
+ }
17
37
 
18
38
  /**
19
39
  * Validate authored block syntax with positions in the original file.
@@ -39,6 +59,7 @@ export function markupFindings(source, file, syntax, runs) {
39
59
  ...(scanned?.blocks ?? scanBlocks(body)).errors,
40
60
  ...(scanned?.alerts ?? scanAlerts(body)).errors,
41
61
  ...(marked?.errors ?? []),
62
+ ...shortcodeErrors(body),
42
63
  ].map((error) => ({
43
64
  file,
44
65
  ...error,
@@ -0,0 +1,134 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **A metre is declared once, as an entry of a `doc` note's `data.metres`**:
16
+ * its `name`, the `beats` of a line and the `kind` of count. This module holds
17
+ * the declaration and its field check, and imports nothing that reads the note
18
+ * vocabulary, which declares the field with them; reading the metres of an
19
+ * index and counting a verse against them is {@link module:engine/verse-metre}.
20
+ *
21
+ * @module
22
+ */
23
+
24
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
25
+
26
+ /** Whether a value is a plain map. */
27
+ const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
28
+
29
+ /** A list value, or an empty one. */
30
+ const listOf = (value) => (Array.isArray(value) ? value : []);
31
+
32
+ /** What a metre's `kind` may be. */
33
+ export const METRE_KINDS = Object.freeze(["accentual"]);
34
+
35
+ /** What a metre's `name` may be. */
36
+ export const METRE_NAME = /^[a-z0-9-]+$/;
37
+
38
+ /**
39
+ * One declared metre.
40
+ *
41
+ * @typedef {object} Metre
42
+ * @property {string} name - What `scan=` names it by.
43
+ * @property {number} beats - The beats of a line.
44
+ * @property {string} kind - How they are counted: `accentual`.
45
+ */
46
+
47
+ /** The notes declaring each metre name, read once per lint index. */
48
+ const declaredNames = new WeakMap();
49
+
50
+ /**
51
+ * Each metre name the index's notes declare, and the notes declaring it.
52
+ *
53
+ * @param {{notes?: Iterable<{file: string, fm?: Record<string, any>}>}} index - The lint index.
54
+ * @returns {Map<string, string[]>} Name → the files that declare it.
55
+ */
56
+ function namesIn(index) {
57
+ const known = declaredNames.get(index);
58
+ if (known) return known;
59
+ const names = new Map();
60
+ for (const note of index.notes ?? [])
61
+ for (const entry of listOf(note?.fm?.data?.metres)) {
62
+ if (!isMap(entry) || typeof entry.name !== "string") continue;
63
+ if (!names.has(entry.name)) names.set(entry.name, new Set());
64
+ names.get(entry.name).add(note.file);
65
+ }
66
+ const out = new Map([...names].map(([name, files]) => [name, [...files]]));
67
+ declaredNames.set(index, out);
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Check a note's `data.metres`: each name's form, that no other entry declares
73
+ * it, and that a beat count is a whole number above zero. The data-key check
74
+ * owns every key, its kind and the closed `kind` list.
75
+ *
76
+ * @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
77
+ * @param {{index?: {notes?: Iterable<object>}}} [context] - The lint index.
78
+ * @returns {object[]} The findings, each at its key.
79
+ */
80
+ export function checkMetresField(note, { index } = {}) {
81
+ const list = note?.fm?.data?.metres;
82
+ if (!Array.isArray(list)) return [];
83
+ const raw = note.raw ?? "";
84
+ const finding = (keyPath, message) => ({
85
+ file: note.file,
86
+ ...positionOfFrontmatterPath(raw, ["data", "metres", ...keyPath]),
87
+ severity: "error",
88
+ message,
89
+ });
90
+ const findings = [];
91
+ const seen = new Set();
92
+ list.forEach((entry, at) => {
93
+ if (!isMap(entry)) return;
94
+ const { name, beats } = entry;
95
+ if (typeof name === "string") {
96
+ const label = `data.metres[${at}].name`;
97
+ if (!METRE_NAME.test(name))
98
+ findings.push(
99
+ finding(
100
+ [at, "name"],
101
+ `\`${label}\` is \`${name}\`; a metre's name is lowercase letters, digits and \`-\``,
102
+ ),
103
+ );
104
+ else if (seen.has(name))
105
+ findings.push(
106
+ finding(
107
+ [at, "name"],
108
+ `\`${label}\` is \`${name}\`, which this note declares twice; a metre's name is unique`,
109
+ ),
110
+ );
111
+ else {
112
+ const others = (index ? (namesIn(index).get(name) ?? []) : []).filter(
113
+ (file) => file !== note.file,
114
+ );
115
+ if (others.length)
116
+ findings.push(
117
+ finding(
118
+ [at, "name"],
119
+ `\`${label}\` is \`${name}\`, which ${others.join(", ")} also declares; a metre's name is unique`,
120
+ ),
121
+ );
122
+ }
123
+ seen.add(name);
124
+ }
125
+ if (beats !== undefined && Number.isFinite(Number(beats)) && !(Number(beats) >= 1))
126
+ findings.push(
127
+ finding(
128
+ [at, "beats"],
129
+ `\`data.metres[${at}].beats\` is \`${String(beats)}\`; a metre has one or more beats`,
130
+ ),
131
+ );
132
+ });
133
+ return findings;
134
+ }
@@ -16,8 +16,8 @@
16
16
  * exactly as the content index does ({@link splitFrontmatter} and `yaml`), so a
17
17
  * value reads the same on the site and in the preview as in a compendium pack.
18
18
  * Writing serializes with `js-yaml`, which quotes every string a YAML 1.1
19
- * reader would take for a boolean, a number or a timestamp, so Hugo reads each
20
- * value back as the string or number it was.
19
+ * reader would take for a boolean, a number or a timestamp, so each
20
+ * value reads back as the string or number it was.
21
21
  *
22
22
  * @module
23
23
  */