@heroiclands/package-build 23.12.8 → 24.0.0

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 (258) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +3 -2
  3. package/assets/preview/gitea.ini +12 -7
  4. package/assets/styles/content-base.css +293 -24
  5. package/bin/content-commands.mjs +370 -213
  6. package/bin/package-build-preview.mjs +2 -2
  7. package/bin/package-build.mjs +3 -2
  8. package/ci/check-banners.mjs +7 -7
  9. package/config.mjs +21 -6
  10. package/content-config.mjs +199 -80
  11. package/docs/api.md +110 -89
  12. package/docs/authoring/links-and-markup.md +469 -97
  13. package/docs/commands.md +259 -78
  14. package/docs/configuration.md +211 -103
  15. package/docs/content-format.md +4 -4
  16. package/docs/diagnostics.md +44 -2
  17. package/docs/getting-started.md +1 -1
  18. package/docs/guides/icons.md +1 -1
  19. package/docs/guides/module-project.md +5 -8
  20. package/docs/guides/release-and-deploy.md +2 -2
  21. package/docs/guides/site.md +112 -23
  22. package/docs/guides/system-project.md +1 -1
  23. package/docs/guides/writing-in-invented-scripts.md +1247 -0
  24. package/docs/index.md +1 -0
  25. package/docs/project-setup.md +28 -30
  26. package/docs/reference/format-details.md +677 -319
  27. package/docs/reference/note-types.md +47 -10
  28. package/docs/reference/theme-pages.md +178 -108
  29. package/docs/source-preview.md +16 -2
  30. package/engine/anchors.mjs +2 -2
  31. package/engine/asset-shipping.mjs +22 -10
  32. package/engine/base-compiler.mjs +62 -104
  33. package/engine/changelog-lint.mjs +1 -1
  34. package/engine/code-fences.mjs +3 -3
  35. package/engine/content-address.mjs +1 -1
  36. package/engine/content-alerts.mjs +8 -4
  37. package/engine/content-blocks.mjs +29 -14
  38. package/engine/content-embeds.mjs +1 -1
  39. package/engine/content-expand.mjs +376 -0
  40. package/engine/content-figures.mjs +18 -11
  41. package/engine/content-format.yaml +8 -0
  42. package/engine/content-glyphs.mjs +213 -0
  43. package/engine/content-images.mjs +6 -7
  44. package/engine/content-index.mjs +1 -1
  45. package/engine/content-links.mjs +3 -3
  46. package/engine/content-lint.mjs +1 -1
  47. package/engine/content-render.mjs +453 -0
  48. package/engine/content-scripts.mjs +376 -43
  49. package/engine/content-spans.mjs +6 -3
  50. package/engine/content-styles.mjs +91 -0
  51. package/engine/content-symbols.mjs +186 -0
  52. package/engine/content-verse.mjs +738 -0
  53. package/engine/core-report.mjs +169 -0
  54. package/engine/core-vocabulary.mjs +208 -0
  55. package/engine/dictionary-html.mjs +119 -0
  56. package/engine/dictionary-rows.mjs +15 -4
  57. package/engine/field-reference.mjs +5 -5
  58. package/engine/first-use.mjs +17 -27
  59. package/engine/frontmatter-lint.mjs +1 -1
  60. package/engine/heading-attributes.mjs +89 -0
  61. package/engine/helpers.mjs +61 -101
  62. package/engine/homepage.mjs +17 -29
  63. package/engine/html-minify.mjs +70 -0
  64. package/engine/index.mjs +8 -5
  65. package/engine/key-case-collisions.mjs +136 -0
  66. package/engine/language-data.mjs +203 -6
  67. package/engine/language-rows.mjs +8 -2
  68. package/engine/language-terms.mjs +186 -16
  69. package/engine/language-words.mjs +12 -0
  70. package/engine/logographic.mjs +255 -0
  71. package/engine/map-lexicon.mjs +262 -47
  72. package/engine/map-places.mjs +5 -4
  73. package/engine/markdown-expressions.mjs +0 -2
  74. package/engine/markdownlint-run.mjs +9 -3
  75. package/engine/markup-validation.mjs +21 -0
  76. package/engine/metre-declaration.mjs +134 -0
  77. package/engine/names.mjs +36 -9
  78. package/engine/note-matter.mjs +66 -0
  79. package/engine/note-vocabulary.mjs +187 -3
  80. package/engine/pdf-build.mjs +69 -133
  81. package/engine/pdf-render.mjs +205 -58
  82. package/engine/post-fields.mjs +157 -0
  83. package/engine/preview-index.mjs +130 -0
  84. package/engine/preview-source.mjs +450 -0
  85. package/engine/project-init.mjs +5 -12
  86. package/engine/prose-config.mjs +3 -3
  87. package/engine/read-stdin.mjs +29 -0
  88. package/engine/run-rows.mjs +2 -2
  89. package/engine/run-words.mjs +6 -112
  90. package/engine/script-emphasis-terms.mjs +38 -0
  91. package/engine/script-emphasis.mjs +295 -0
  92. package/engine/script-font-delivery.mjs +4 -4
  93. package/engine/site-build.mjs +260 -315
  94. package/engine/site-config.mjs +26 -238
  95. package/engine/site-files.mjs +175 -0
  96. package/engine/site-index.mjs +9 -4
  97. package/engine/site-preview.mjs +52 -525
  98. package/engine/site-render.mjs +593 -0
  99. package/engine/site-root.mjs +22 -13
  100. package/engine/site-search.mjs +161 -0
  101. package/engine/site-serve.mjs +273 -0
  102. package/engine/site-theme-banner.mjs +154 -0
  103. package/engine/site-theme-urls.mjs +125 -0
  104. package/engine/site-theme.mjs +926 -0
  105. package/engine/sql-tables.mjs +20 -3
  106. package/engine/text-maps.mjs +57 -10
  107. package/engine/typographer.mjs +248 -0
  108. package/engine/verse-metre.mjs +357 -0
  109. package/engine/wikilink-syntax.mjs +29 -47
  110. package/engine/wikilinks.mjs +758 -279
  111. package/engine/word-forms.mjs +364 -0
  112. package/engine/word-rows.mjs +12 -2
  113. package/engine/word-terms.mjs +38 -4
  114. package/manifest.mjs +3 -4
  115. package/package.json +11 -8
  116. package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
  117. package/site-theme/layouts/base.hbs +31 -0
  118. package/site-theme/layouts/blog-list.hbs +30 -0
  119. package/site-theme/layouts/blog-post.hbs +40 -0
  120. package/site-theme/layouts/home.hbs +35 -0
  121. package/site-theme/layouts/list.hbs +20 -0
  122. package/site-theme/layouts/not-found.hbs +31 -0
  123. package/site-theme/layouts/page.hbs +7 -0
  124. package/site-theme/partials/copy-buttons.hbs +37 -0
  125. package/site-theme/partials/draft-notice.hbs +5 -0
  126. package/site-theme/partials/exturl.hbs +2 -0
  127. package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
  128. package/site-theme/partials/header.hbs +44 -0
  129. package/site-theme/partials/hero-banner.hbs +11 -0
  130. package/site-theme/partials/infobox-entry.hbs +2 -0
  131. package/site-theme/partials/infobox-section.hbs +31 -0
  132. package/site-theme/partials/infobox-value.hbs +2 -0
  133. package/site-theme/partials/infobox.hbs +18 -0
  134. package/site-theme/partials/list-members.hbs +9 -0
  135. package/site-theme/partials/nav-links.hbs +5 -0
  136. package/site-theme/partials/page-body.hbs +48 -0
  137. package/site-theme/partials/page-list.hbs +14 -0
  138. package/site-theme/partials/related.hbs +24 -0
  139. package/site-theme/partials/search-meta.hbs +6 -0
  140. package/site-theme/partials/search-names.hbs +2 -0
  141. package/site-theme/partials/search.hbs +52 -0
  142. package/site-theme/partials/tags.hbs +4 -0
  143. package/site-theme/partials/toc.hbs +7 -0
  144. package/{hugo-theme → site-theme}/static/css/style.css +106 -14
  145. package/stage.mjs +1 -2
  146. package/types/config.d.mts +8 -4
  147. package/types/content-config.d.mts +15 -15
  148. package/types/engine/asset-shipping.d.mts +5 -2
  149. package/types/engine/base-compiler.d.mts +20 -2
  150. package/types/engine/code-fences.d.mts +3 -3
  151. package/types/engine/content-address.d.mts +1 -1
  152. package/types/engine/content-alerts.d.mts +8 -2
  153. package/types/engine/content-blocks.d.mts +11 -4
  154. package/types/engine/content-expand.d.mts +288 -0
  155. package/types/engine/content-figures.d.mts +11 -3
  156. package/types/engine/content-glyphs.d.mts +85 -0
  157. package/types/engine/content-images.d.mts +6 -7
  158. package/types/engine/content-links.d.mts +1 -1
  159. package/types/engine/content-render.d.mts +199 -0
  160. package/types/engine/content-scripts.d.mts +70 -4
  161. package/types/engine/content-spans.d.mts +1 -1
  162. package/types/engine/content-styles.d.mts +33 -0
  163. package/types/engine/content-symbols.d.mts +35 -0
  164. package/types/engine/content-verse.d.mts +240 -0
  165. package/types/engine/core-report.d.mts +45 -0
  166. package/types/engine/core-vocabulary.d.mts +53 -0
  167. package/types/engine/dictionary-html.d.mts +8 -0
  168. package/types/engine/dictionary-rows.d.mts +10 -0
  169. package/types/engine/field-reference.d.mts +2 -2
  170. package/types/engine/heading-attributes.d.mts +20 -0
  171. package/types/engine/helpers.d.mts +3 -10
  172. package/types/engine/homepage.d.mts +11 -24
  173. package/types/engine/html-minify.d.mts +9 -0
  174. package/types/engine/index.d.mts +2 -1
  175. package/types/engine/key-case-collisions.d.mts +28 -0
  176. package/types/engine/language-data.d.mts +0 -2
  177. package/types/engine/language-terms.d.mts +24 -2
  178. package/types/engine/logographic.d.mts +45 -0
  179. package/types/engine/map-lexicon.d.mts +66 -13
  180. package/types/engine/map-places.d.mts +8 -0
  181. package/types/engine/markdown-expressions.d.mts +0 -2
  182. package/types/engine/metre-declaration.d.mts +39 -0
  183. package/types/engine/names.d.mts +34 -3
  184. package/types/engine/note-matter.d.mts +48 -0
  185. package/types/engine/pdf-render.d.mts +6 -2
  186. package/types/engine/post-fields.d.mts +44 -0
  187. package/types/engine/preview-index.d.mts +72 -0
  188. package/types/engine/preview-source.d.mts +51 -0
  189. package/types/engine/read-stdin.d.mts +11 -0
  190. package/types/engine/run-rows.d.mts +2 -2
  191. package/types/engine/run-words.d.mts +0 -15
  192. package/types/engine/script-emphasis-terms.d.mts +21 -0
  193. package/types/engine/script-emphasis.d.mts +84 -0
  194. package/types/engine/script-font-delivery.d.mts +4 -4
  195. package/types/engine/site-build.d.mts +71 -113
  196. package/types/engine/site-config.d.mts +14 -134
  197. package/types/engine/site-files.d.mts +73 -0
  198. package/types/engine/site-index.d.mts +11 -3
  199. package/types/engine/site-preview.d.mts +15 -19
  200. package/types/engine/site-render.d.mts +171 -0
  201. package/types/engine/site-root.d.mts +12 -4
  202. package/types/engine/site-search.d.mts +54 -0
  203. package/types/engine/site-serve.d.mts +44 -0
  204. package/types/engine/site-theme-banner.d.mts +51 -0
  205. package/types/engine/site-theme-urls.d.mts +60 -0
  206. package/types/engine/site-theme.d.mts +127 -0
  207. package/types/engine/sql-tables.d.mts +4 -1
  208. package/types/engine/text-maps.d.mts +17 -3
  209. package/types/engine/typographer.d.mts +11 -0
  210. package/types/engine/verse-metre.d.mts +131 -0
  211. package/types/engine/wikilink-syntax.d.mts +30 -48
  212. package/types/engine/wikilinks.d.mts +330 -14
  213. package/types/engine/word-forms.d.mts +83 -0
  214. package/types/manifest.d.mts +3 -4
  215. package/types/stage.d.mts +1 -2
  216. package/types/vendor/markdownlint.d.mts +8 -0
  217. package/vendor/markdownlint.mjs +20388 -0
  218. package/engine/content-poetry.mjs +0 -240
  219. package/engine/web-wikilinks.mjs +0 -594
  220. package/hugo-theme/layouts/404.html +0 -85
  221. package/hugo-theme/layouts/_default/baseof.html +0 -67
  222. package/hugo-theme/layouts/_default/list.html +0 -139
  223. package/hugo-theme/layouts/_default/single.html +0 -4
  224. package/hugo-theme/layouts/blog/list.html +0 -54
  225. package/hugo-theme/layouts/blog-post/single.html +0 -36
  226. package/hugo-theme/layouts/index.html +0 -75
  227. package/hugo-theme/layouts/partials/banner-url.html +0 -141
  228. package/hugo-theme/layouts/partials/cdn-url.html +0 -36
  229. package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
  230. package/hugo-theme/layouts/partials/draft-notice.html +0 -41
  231. package/hugo-theme/layouts/partials/draft-state.html +0 -26
  232. package/hugo-theme/layouts/partials/header.html +0 -35
  233. package/hugo-theme/layouts/partials/hero-banner.html +0 -85
  234. package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
  235. package/hugo-theme/layouts/partials/infobox/items.html +0 -26
  236. package/hugo-theme/layouts/partials/infobox/section.html +0 -56
  237. package/hugo-theme/layouts/partials/infobox/value.html +0 -43
  238. package/hugo-theme/layouts/partials/infobox.html +0 -57
  239. package/hugo-theme/layouts/partials/page-body.html +0 -152
  240. package/hugo-theme/layouts/partials/related.html +0 -138
  241. package/hugo-theme/layouts/partials/search.html +0 -84
  242. package/hugo-theme/layouts/partials/site-url.html +0 -32
  243. package/hugo-theme/layouts/partials/tags.html +0 -30
  244. package/hugo-theme/layouts/partials/toc.html +0 -28
  245. package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
  246. package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
  247. package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
  248. package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
  249. package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
  250. package/hugo-theme/theme.toml +0 -11
  251. package/types/engine/content-poetry.d.mts +0 -58
  252. package/types/engine/web-wikilinks.d.mts +0 -107
  253. /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
  254. /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
  255. /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
  256. /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
  257. /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
  258. /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
@@ -28,6 +28,7 @@
28
28
 
29
29
  import { parseAddress, readCanonicalKey, renderAddress, isAddressTuple } from "./address.mjs";
30
30
  import { collectAnchors } from "./anchors.mjs";
31
+ import { keyCaseCollisionDiagnostics } from "./key-case-collisions.mjs";
31
32
  import { expandContentTables } from "./content-tables.mjs";
32
33
  import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
33
34
  import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs";
@@ -260,12 +261,14 @@ export function findSqlBlocks(markdown) {
260
261
  * @param {object} [opts.addressContext] - Address resolution context.
261
262
  * @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes from
262
263
  * local and dependency relations.
264
+ * @param {string} [opts.contentBase] - The directory the records' paths are
265
+ * relative to, so a refused key is reported at its file and line.
263
266
  * @returns {Promise<{query: (sql: string) => Promise<object[]>,
264
267
  * close: () => Promise<void>}>} The open database.
265
268
  */
266
269
  export async function openNotesDatabase(
267
270
  records,
268
- { dir, dependencies = [], addressContext, audience = "all" } = {},
271
+ { dir, dependencies = [], addressContext, audience = "all", contentBase } = {},
269
272
  ) {
270
273
  const { DuckDBInstance } = await import("@duckdb/node-api");
271
274
  const base = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
@@ -276,7 +279,20 @@ export async function openNotesDatabase(
276
279
  const instance = await DuckDBInstance.create(":memory:");
277
280
  const connection = await instance.connect();
278
281
  await connection.run("SET threads=1");
279
- await createRelations(connection, jsonl, encoded, "", audience);
282
+ try {
283
+ await createRelations(connection, jsonl, encoded, "", audience);
284
+ } catch (err) {
285
+ // DuckDB names the clashing key but not the notes that spell it.
286
+ if (/Duplicate name ".*" in struct/.test(String(err?.message))) {
287
+ const lines = keyCaseCollisionDiagnostics(records, { contentBase });
288
+ if (lines.length) {
289
+ const located = new Error(lines.join("\n"));
290
+ /** @type {any} */ (located).located = true;
291
+ throw located;
292
+ }
293
+ }
294
+ throw err;
295
+ }
280
296
  await createEventRelation(connection, path.join(base, "events.jsonl"), encoded, "", audience);
281
297
  await createNameRelation(connection, path.join(base, "names.jsonl"), encoded, "", audience);
282
298
  await createRunRelation(connection, path.join(base, "runs.jsonl"), encoded, "", audience);
@@ -630,7 +646,7 @@ async function createNameRelation(connection, file, records, prefix, audience) {
630
646
  }
631
647
 
632
648
  /**
633
- * Create the `runs` relation: one row per span, div and poetry fence an own
649
+ * Create the `runs` relation: one row per span, div and verse fence an own
634
650
  * note marks with `lang=`, `font=` or `map=`, with the columns
635
651
  * {@link module:engine/run-rows.RUN_COLUMNS} names. The columns are declared
636
652
  * rather than inferred, so every value stays a string and none becomes a
@@ -1732,6 +1748,7 @@ export async function prepareTreeSqlTables(
1732
1748
  (await openNotesDatabase(allRecords, {
1733
1749
  dependencies: await cachedDependencies(config),
1734
1750
  audience,
1751
+ contentBase,
1735
1752
  }));
1736
1753
  try {
1737
1754
  const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
@@ -51,6 +51,14 @@
51
51
  * `uncapitalised`, which a run reports; the capitalised word is then stated
52
52
  * as a lexicon word of its own.
53
53
  *
54
+ * - **A logographic map** (`logographic: true`) writes every word, a word
55
+ * being a match of the word pattern that holds a letter, by the writing a
56
+ * lookup gives it (see {@link module:engine/logographic}), and its rows
57
+ * write only the text between words: spaces, points, and anything else
58
+ * with no letter. A word the lookup gives no writing is written as typed
59
+ * and marked `unwritten`, which a run reports. A row of such a map whose
60
+ * `from` holds a letter could never apply, and is refused.
61
+ *
54
62
  * `rows` is a list, never a keyed map, so `A` and `a` are two rows: the index
55
63
  * carries them as rows of the `maps` relation, never as struct keys.
56
64
  *
@@ -97,6 +105,8 @@ const BOUNDARY = /[\s\p{P}\p{S}]/u;
97
105
  *
98
106
  * @typedef {object} TextMap
99
107
  * @property {string} name - Its name.
108
+ * @property {boolean} logographic - Whether it writes words from the lexicon
109
+ * alone, its rows only between them.
100
110
  * @property {ReadonlySet<string>} vowels - The characters `before: vowel` reads.
101
111
  * @property {readonly MapRow[]} rows - Its well-formed shared rows, in authored order.
102
112
  * @property {ReadonlyMap<string, readonly MapRow[]>} byFirst - Its shared rows
@@ -113,6 +123,8 @@ const BOUNDARY = /[\s\p{P}\p{S}]/u;
113
123
  * tried first.
114
124
  * @property {(word: string) => string|undefined} [native] - A word's native
115
125
  * form, for the word exactly as given, or `undefined` where it states none.
126
+ * @property {(word: string) => import("./logographic.mjs").Writing} [writing] -
127
+ * A word's writing under a logographic map; without one, no word has one.
116
128
  */
117
129
 
118
130
  /**
@@ -236,6 +248,7 @@ export function compileMap(value) {
236
248
  }
237
249
  return Object.freeze({
238
250
  name: map.name,
251
+ logographic: map.logographic === true,
239
252
  vowels,
240
253
  rows,
241
254
  byFirst: indexRows(rows),
@@ -300,9 +313,9 @@ function rowAt(map, font, text, at, limit) {
300
313
  * @param {TextMap|undefined} map - The map.
301
314
  * @param {string} text - The text.
302
315
  * @param {string|undefined} font - The face.
303
- * @param {ReadonlyMap<number, {end: number, to: string, uncapitalised?: string}>} words -
304
- * Words written by their native form, by offset.
305
- * @returns {{text: string, pieces: Array<{at: number, from: string, to: string, row: boolean, native?: boolean, uncapitalised?: string}>}}
316
+ * @param {ReadonlyMap<number, {end: number, to: string, uncapitalised?: string, unwritten?: object}>} words -
317
+ * Words written whole, by offset.
318
+ * @returns {{text: string, pieces: Array<{at: number, from: string, to: string, row: boolean, native?: boolean, uncapitalised?: string, unwritten?: object}>}}
306
319
  * As {@link applyMap}.
307
320
  */
308
321
  function applyRows(map, text, font, words) {
@@ -317,8 +330,9 @@ function applyRows(map, text, font, words) {
317
330
  at,
318
331
  from: text.slice(at, word.end),
319
332
  to: word.to,
320
- row: true,
321
- native: true,
333
+ ...(word.unwritten === undefined ?
334
+ { row: true, native: true }
335
+ : { row: false, unwritten: word.unwritten }),
322
336
  ...(word.uncapitalised === undefined ? {} : { uncapitalised: word.uncapitalised }),
323
337
  });
324
338
  out += word.to;
@@ -371,15 +385,31 @@ export function capitalNative(map, font, word, native) {
371
385
  * as it is.
372
386
  * @param {string} text - The text, with no escapes in it.
373
387
  * @param {MapOptions} [options] - The face, and the native forms of words.
374
- * @returns {{text: string, pieces: Array<{at: number, from: string, to: string, row: boolean, native?: boolean}>}}
388
+ * @returns {{text: string, pieces: Array<{at: number, from: string, to: string, row: boolean, native?: boolean, unwritten?: object}>}}
375
389
  * The rewritten text, and each piece of it with the UTF-16 offset in `text`
376
390
  * it came from: a matched row's `from` and what it wrote, a word and its
377
391
  * native form (`native: true`), or one character that passed through. A
378
392
  * capitalised word whose native form the rows cannot capitalise is written
379
393
  * as stated and carries `uncapitalised`, the lowercase form it was found by.
394
+ * Under a logographic map, a word with no writing is written as typed and
395
+ * carries `unwritten`, why it has none.
380
396
  */
381
- export function applyMap(map, text, { font, native } = {}) {
397
+ export function applyMap(map, text, { font, native, writing } = {}) {
382
398
  const words = new Map();
399
+ if (map?.logographic) {
400
+ for (const match of text.matchAll(WORD)) {
401
+ const word = match[0];
402
+ if (!LETTER.test(word)) continue;
403
+ const found = writing ? writing(word) : { missing: true, noLexicon: true };
404
+ words.set(
405
+ match.index,
406
+ "text" in found ?
407
+ { end: match.index + word.length, to: found.text }
408
+ : { end: match.index + word.length, to: word, unwritten: found },
409
+ );
410
+ }
411
+ return applyRows(map, text, font, words);
412
+ }
383
413
  if (map && native)
384
414
  for (const match of text.matchAll(WORD)) {
385
415
  const word = match[0];
@@ -502,7 +532,8 @@ function namesIn(index) {
502
532
  /**
503
533
  * Check a note's `data.map`: its name, that no other note declares it, each
504
534
  * row written as a list, a `before` condition on a map with no `vowels`, an
505
- * empty `fonts`, and a generic family in it. The data-key check owns every
535
+ * empty `fonts`, a generic family in it, and a row of a logographic map that
536
+ * matches a letter. The data-key check owns every
506
537
  * key, its kind and the words a condition takes; whether a listed face is
507
538
  * installed, and draws what the map writes, is
508
539
  * {@link module:engine/map-lexicon.checkMapFonts}'s.
@@ -527,7 +558,9 @@ export function checkMapField(note, { index } = {}) {
527
558
  message,
528
559
  });
529
560
  const findings = [];
530
- const { name, vowels, rows, fonts } = /** @type {Record<string, unknown>} */ (value);
561
+ const { name, vowels, rows, fonts, logographic } = /** @type {Record<string, unknown>} */ (
562
+ value
563
+ );
531
564
  if (typeof name === "string" && !MAP_NAME.test(name))
532
565
  findings.push(
533
566
  finding(
@@ -565,12 +598,26 @@ export function checkMapField(note, { index } = {}) {
565
598
  }
566
599
  // A row's keys, their kinds and the words a condition takes are the
567
600
  // data-key check's; what it cannot say is a list row's arity, an empty
568
- // `from` in one, and a condition the map has no vowels for.
601
+ // `from` in one, a condition the map has no vowels for, and a letter a
602
+ // logographic map's row could never meet.
569
603
  const hasVowels = typeof vowels === "string" && vowels !== "";
570
604
  const rowFindings = (list, keyPath) => {
571
605
  if (!Array.isArray(list)) return;
572
606
  const label = `data.map.${keyPath.join(".").replace(/\.(\d+)/g, "[$1]")}`;
573
607
  list.forEach((row, at) => {
608
+ const from =
609
+ Array.isArray(row) ? row[0]
610
+ : isMap(row) ? row.from
611
+ : undefined;
612
+ const letter = typeof from === "string" ? from.match(LETTER)?.[0] : undefined;
613
+ if (logographic === true && letter !== undefined)
614
+ findings.push(
615
+ finding(
616
+ [...keyPath, at],
617
+ `\`${label}[${at}]\` matches \`${letter}\`, a letter, and the rows of a ` +
618
+ "logographic map write only the text between words",
619
+ ),
620
+ );
574
621
  if (Array.isArray(row)) {
575
622
  if (row.length !== 2)
576
623
  findings.push(
@@ -0,0 +1,248 @@
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
+ * **Typographic punctuation on the web.** A web page sets straight quotes as
16
+ * curly ones and a run of hyphens or full stops as the dash or ellipsis it
17
+ * stands for, as a printed page does:
18
+ *
19
+ * | Written | Set as |
20
+ * | ------- | ------ |
21
+ * | `"word"` | “word” |
22
+ * | `'word'` | ‘word’ |
23
+ * | `it's`, `'90s`, `'twas` | it’s, ’90s, ’twas |
24
+ * | `--` | – |
25
+ * | `---` | — |
26
+ * | `...` | … |
27
+ * | `<<`, `>>` | «, » |
28
+ *
29
+ * A quote is read by the characters either side of it, as emphasis is: one
30
+ * that can only open a quotation opens one, one that can only close it closes
31
+ * a quotation already open earlier on the page, and one that could do either
32
+ * is left straight. Code, link destinations, raw HTML and a heading's
33
+ * attribute block are never touched, and neither is a backslash-escaped
34
+ * character.
35
+ *
36
+ * @module
37
+ */
38
+
39
+ /** The characters markdown-it's text rule stops at, and the ones this rule reads. */
40
+ const STOPS = new Set([..."\n!#$%&*+-:<=@[\\]^_`{}~", '"', "'", ".", ">"]);
41
+
42
+ /**
43
+ * The token a typographic character is written as. It is a token of its own,
44
+ * not text, so a reader of a heading's text can tell where it stands.
45
+ */
46
+ export const TYPOGRAPHIC = "typographic";
47
+
48
+ /** The characters this rule reads. */
49
+ const TRIGGERS = new Set(['"', "'", "-", ".", "<", ">"]);
50
+
51
+ const LEFT_SINGLE = "‘";
52
+ const RIGHT_SINGLE = "’";
53
+ const LEFT_DOUBLE = "“";
54
+ const RIGHT_DOUBLE = "”";
55
+
56
+ /** ASCII punctuation, as a byte table reads it. */
57
+ const isAsciiPunct = (char) => char !== undefined && /^[!-/:-@[-`{-~]$/.test(char);
58
+
59
+ /** ASCII white space, as a byte table reads it. */
60
+ const isAsciiSpace = (char) => char === " " || char === "\t" || char === "\n" || char === "\r";
61
+
62
+ /** Unicode punctuation. */
63
+ const isPunct = (char) => char !== undefined && /^\p{P}$/u.test(char);
64
+
65
+ /** Unicode punctuation or a symbol. */
66
+ const isPunctOrSymbol = (char) => char !== undefined && /^[\p{P}\p{S}]$/u.test(char);
67
+
68
+ /** Unicode white space. */
69
+ const isSpace = (char) => char !== undefined && (isAsciiSpace(char) || /^\s$/u.test(char));
70
+
71
+ const isLetter = (char) => char !== undefined && /^\p{L}$/u.test(char);
72
+ const isDigit = (char) => char !== undefined && /^\p{Nd}$/u.test(char);
73
+ const isAsciiDigit = (char) => char !== undefined && char >= "0" && char <= "9";
74
+
75
+ /**
76
+ * Whether a run of quote characters can open and can close, by the flanking
77
+ * rules emphasis uses.
78
+ *
79
+ * @param {string} before - The character before the run, or a newline.
80
+ * @param {string} after - The character after it, or a space.
81
+ * @returns {{canOpen: boolean, canClose: boolean}} What the run can do.
82
+ */
83
+ function flanking(before, after) {
84
+ const beforePunct = isPunctOrSymbol(before);
85
+ const beforeSpace = isSpace(before);
86
+ const afterPunct = isPunctOrSymbol(after);
87
+ const afterSpace = isSpace(after);
88
+ return {
89
+ canOpen: !afterSpace && (!afterPunct || beforeSpace || beforePunct),
90
+ canClose: !beforeSpace && (!beforePunct || afterSpace || afterPunct),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Where a trailing attribute block starts in an inline run, so a heading's
96
+ * `{#id key="value"}` keeps its straight quotes and hyphens.
97
+ *
98
+ * @param {string} src - The inline run.
99
+ * @returns {number} The index of its opening brace, or the run's length.
100
+ */
101
+ function attributeStart(src) {
102
+ const match = /\{[^{}\n]*\}\s*$/.exec(src);
103
+ return match ? match.index : src.length;
104
+ }
105
+
106
+ /**
107
+ * The punctuation one position sets, and how many characters it consumes.
108
+ *
109
+ * @param {object} state - markdown-it's inline state.
110
+ * @returns {{text: string, length: number}|null} The replacement, or `null`
111
+ * where the character stays as written.
112
+ */
113
+ function substitution(state) {
114
+ const { src, pos } = state;
115
+ const end = src.indexOf("\n", pos);
116
+ const line = [...src.slice(pos, end < 0 ? src.length : end + 1)];
117
+ const c = line[0];
118
+ if (line.length > 2) {
119
+ if (c === "-" && line[1] === "-" && line[2] === "-") return { text: "—", length: 3 };
120
+ if (c === "." && line[1] === "." && line[2] === ".") return { text: "…", length: 3 };
121
+ }
122
+ if (c === ".") return null;
123
+ if (line.length > 1) {
124
+ if (c === "<") return line[1] === "<" ? { text: "«", length: 2 } : null;
125
+ if (c === ">") return line[1] === ">" ? { text: "»", length: 2 } : null;
126
+ if (c === "-" && line[1] === "-") return { text: "–", length: 2 };
127
+ }
128
+ if (c !== "'" && c !== '"') return null;
129
+
130
+ const before = pos > 0 ? [...src.slice(0, pos)].at(-1) : "\n";
131
+ let run = 0;
132
+ while (run < line.length && line[run] === c) run++;
133
+ const { canOpen, canClose } = flanking(before, run < line.length ? line[run] : " ");
134
+ const counter = (state.env.typographerQuotes ??= { single: 0, double: 0 });
135
+ const one = (text) => ({ text, length: 1 });
136
+
137
+ if (c === "'") {
138
+ // A decade: '90s.
139
+ if (
140
+ canOpen &&
141
+ !canClose &&
142
+ line.length > 3 &&
143
+ isAsciiDigit(line[1]) &&
144
+ isAsciiDigit(line[2]) &&
145
+ line[3] === "s"
146
+ ) {
147
+ const after = line.length > 4 ? line[4] : " ";
148
+ if (isSpace(after) || isPunctOrSymbol(after)) return one(RIGHT_SINGLE);
149
+ }
150
+ // An elision: 'twas, 'em, 'net.
151
+ if (
152
+ line.length > 1 &&
153
+ (isPunct(before) || isSpace(before)) &&
154
+ ["t", "e", "n", "l"].includes(line[1])
155
+ )
156
+ return one(RIGHT_SINGLE);
157
+ // An apostrophe between a letter or digit and a letter.
158
+ if (line.length > 1 && (isDigit(before) || isLetter(before)) && isLetter(line[1]))
159
+ return one(RIGHT_SINGLE);
160
+ if (canOpen && !canClose) {
161
+ const contracted =
162
+ (line.length > 1 &&
163
+ ["s", "m", "t", "d"].includes(line[1]) &&
164
+ (line.length < 3 || isAsciiPunct(line[2]) || isAsciiSpace(line[2]))) ||
165
+ (line.length > 2 &&
166
+ ["ve", "ll", "re"].includes(`${line[1]}${line[2]}`) &&
167
+ (line.length < 4 || isAsciiPunct(line[3]) || isAsciiSpace(line[3])));
168
+ if (contracted) return one(RIGHT_SINGLE);
169
+ counter.single++;
170
+ return one(LEFT_SINGLE);
171
+ }
172
+ // A plural possessive or a dropped letter: Smiths', doin'.
173
+ if (line.length > 2 && !isDigit(line[1])) return one(RIGHT_SINGLE);
174
+ if (counter.single > 0 && closes(canOpen, canClose, line)) {
175
+ counter.single--;
176
+ return one(RIGHT_SINGLE);
177
+ }
178
+ return null;
179
+ }
180
+
181
+ if (canOpen && !canClose) {
182
+ counter.double++;
183
+ return one(LEFT_DOUBLE);
184
+ }
185
+ if (counter.double > 0 && closes(canOpen, canClose, line)) {
186
+ // A measurement in inches: "Monitor 21"".
187
+ if (line.length > 1 && line[1] === '"' && isDigit(before)) return null;
188
+ counter.double--;
189
+ return one(RIGHT_DOUBLE);
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Whether a quote closes: it can only close, or it could do either and stands
196
+ * before punctuation that ends the line or is followed by white space or more
197
+ * punctuation.
198
+ *
199
+ * @param {boolean} canOpen - The run can open.
200
+ * @param {boolean} canClose - The run can close.
201
+ * @param {string[]} line - The rest of the line, from the quote.
202
+ * @returns {boolean} Whether it closes.
203
+ */
204
+ function closes(canOpen, canClose, line) {
205
+ if (canClose && !canOpen) return true;
206
+ return (
207
+ canClose &&
208
+ canOpen &&
209
+ line.length > 1 &&
210
+ isPunct(line[1]) &&
211
+ (line.length === 2 || isAsciiPunct(line[2]) || isAsciiSpace(line[2]))
212
+ );
213
+ }
214
+
215
+ /**
216
+ * Set typographic punctuation in every inline run.
217
+ *
218
+ * @param {import("markdown-it")} md - Markdown parser to extend.
219
+ */
220
+ export function typographerPlugin(md) {
221
+ md.inline.ruler.at("text", (state, silent) => {
222
+ let pos = state.pos;
223
+ while (pos < state.posMax && !STOPS.has(state.src[pos])) pos++;
224
+ if (pos === state.pos) return false;
225
+ if (!silent) state.pending += state.src.slice(state.pos, pos);
226
+ state.pos = pos;
227
+ return true;
228
+ });
229
+ md.inline.ruler.push("typographer", (state, silent) => {
230
+ if (!TRIGGERS.has(state.src[state.pos])) return false;
231
+ state.typographerEnd ??= attributeStart(state.src);
232
+ if (state.pos >= state.typographerEnd) return false;
233
+ const found = substitution(state);
234
+ if (!found) return false;
235
+ if (!silent) state.push(TYPOGRAPHIC, "", 0).content = found.text;
236
+ state.pos += found.length;
237
+ return true;
238
+ });
239
+ md.renderer.rules[TYPOGRAPHIC] = (tokens, idx) => md.utils.escapeHtml(tokens[idx].content);
240
+ // An image's description is its text, its punctuation included.
241
+ const asText = md.renderer.renderInlineAsText.bind(md.renderer);
242
+ md.renderer.renderInlineAsText = (tokens, options, env) =>
243
+ tokens
244
+ .map((token) =>
245
+ token.type === TYPOGRAPHIC ? token.content : asText([token], options, env),
246
+ )
247
+ .join("");
248
+ }