@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
@@ -603,11 +603,7 @@ async function createEventRelation(connection, file, records, prefix, audience)
603
603
  fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
604
604
  // `whenIn` and `untilIn` are keyed by calendar Address, and read as structs
605
605
  // whatever their number of keys, so `whenIn['<calendar>']` always binds.
606
- await createTableFromFile(
607
- connection,
608
- `${prefix}events`,
609
- readJsonAuto(file).replace(/\)$/, ", map_inference_threshold=-1)"),
610
- );
606
+ await createTableFromFile(connection, `${prefix}events`, readJsonAuto(file));
611
607
  }
612
608
 
613
609
  /**
@@ -941,13 +937,20 @@ async function createRowRelation(connection, file, rows, view, columns, types) {
941
937
  * type's fields with `NULL` where a record lacks one. A dependency's index has
942
938
  * exactly the same shape and needs exactly the same reading.
943
939
  *
940
+ * `map_inference_threshold=-1` keeps every object a struct. Left at its default,
941
+ * DuckDB reads an object whose keys are sparsely spread across rows (the mean
942
+ * share of rows carrying a key falls under `field_appearance_threshold`) as a
943
+ * map, so `data.<key>` changes meaning with the corpus: one more field on one
944
+ * note can turn a plain comparison into a JSON conversion error.
945
+ *
944
946
  * @param {string} file - The JSONL to read.
945
947
  * @returns {string} The `SELECT … FROM read_json_auto(…)` clause.
946
948
  */
947
949
  function readJsonAuto(file) {
948
950
  return (
949
951
  `SELECT * FROM read_json_auto('${file.replace(/'/g, "''")}', ` +
950
- `format='newline_delimited', union_by_name=true, maximum_object_size=20000000)`
952
+ `format='newline_delimited', union_by_name=true, maximum_object_size=20000000, ` +
953
+ `map_inference_threshold=-1)`
951
954
  );
952
955
  }
953
956
 
@@ -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
+ }