@heroiclands/package-build 25.0.0 → 25.1.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 (105) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -0
  3. package/bin/content-commands.mjs +30 -96
  4. package/bin/encoding-commands.mjs +146 -0
  5. package/bin/package-build.mjs +35 -24
  6. package/config.mjs +25 -0
  7. package/convert.mjs +182 -0
  8. package/docs/api.md +137 -6
  9. package/docs/authoring/links-and-markup.md +260 -481
  10. package/docs/commands.md +110 -119
  11. package/docs/configuration.md +31 -13
  12. package/docs/content-format.md +11 -4
  13. package/docs/diagnostics.md +43 -16
  14. package/docs/encoding-core.md +135 -0
  15. package/docs/guides/book.md +2 -2
  16. package/docs/guides/module-project.md +23 -1
  17. package/docs/guides/site.md +2 -2
  18. package/docs/guides/writing-in-invented-scripts.md +450 -574
  19. package/docs/reference/format-details.md +289 -404
  20. package/docs/reference/note-types.md +80 -63
  21. package/docs/source-preview.md +8 -0
  22. package/e2e.mjs +1 -1
  23. package/engine/base-compiler.mjs +27 -9
  24. package/engine/content-charset.mjs +13 -1
  25. package/engine/content-format.yaml +6 -2
  26. package/engine/content-index.mjs +6 -2
  27. package/engine/content-links.mjs +1 -0
  28. package/engine/content-scripts.mjs +614 -459
  29. package/engine/content-scroll.mjs +4 -4
  30. package/engine/content-spans.mjs +40 -9
  31. package/engine/content-verse.mjs +1 -1
  32. package/engine/converter-data.mjs +70 -0
  33. package/engine/dictionary-rows.mjs +2 -34
  34. package/engine/encoding-integrity.mjs +94 -0
  35. package/engine/encoding-language.mjs +506 -0
  36. package/engine/encoding-lexicon.mjs +699 -0
  37. package/engine/encoding-notes.mjs +186 -0
  38. package/engine/encoding-standard.mjs +42 -0
  39. package/engine/encoding-template.mjs +313 -0
  40. package/engine/encoding-terms.mjs +141 -0
  41. package/engine/encoding.mjs +946 -0
  42. package/engine/extension-attributes.mjs +2 -0
  43. package/engine/helpers.mjs +1 -1
  44. package/engine/index.mjs +7 -0
  45. package/engine/language-terms.mjs +17 -16
  46. package/engine/{logographic.mjs → language-writings.mjs} +29 -38
  47. package/engine/markup-validation.mjs +1 -1
  48. package/engine/note-vocabulary.mjs +4 -158
  49. package/engine/pdf-render.mjs +31 -3
  50. package/engine/roman-characters.mjs +135 -16
  51. package/engine/roman-run.mjs +149 -0
  52. package/engine/roman-verse.mjs +95 -0
  53. package/engine/run-rows.mjs +29 -31
  54. package/engine/run-words.mjs +2 -2
  55. package/engine/script-emphasis-terms.mjs +3 -0
  56. package/engine/script-emphasis.mjs +3 -10
  57. package/engine/script-font-delivery.mjs +9 -12
  58. package/engine/script-fonts.mjs +2 -1
  59. package/engine/site-build.mjs +14 -5
  60. package/engine/site-preview.mjs +12 -2
  61. package/engine/sql-tables.mjs +36 -31
  62. package/engine/verse-metre.mjs +1 -1
  63. package/engine/word-forms.mjs +1 -1
  64. package/engine/word-inflections.mjs +48 -0
  65. package/engine/word-rows.mjs +1 -1
  66. package/engine/word-terms.mjs +16 -17
  67. package/package.json +11 -5
  68. package/types/config.d.mts +5 -0
  69. package/types/convert.d.mts +44 -0
  70. package/types/e2e.d.mts +1 -1
  71. package/types/engine/base-compiler.d.mts +4 -2
  72. package/types/engine/content-index.d.mts +4 -1
  73. package/types/engine/content-scripts.d.mts +103 -42
  74. package/types/engine/converter-data.d.mts +11 -0
  75. package/types/engine/dictionary-rows.d.mts +1 -25
  76. package/types/engine/encoding-integrity.d.mts +14 -0
  77. package/types/engine/encoding-language.d.mts +36 -0
  78. package/types/engine/encoding-lexicon.d.mts +259 -0
  79. package/types/engine/encoding-notes.d.mts +39 -0
  80. package/types/engine/encoding-standard.d.mts +11 -0
  81. package/types/engine/encoding-template.d.mts +43 -0
  82. package/types/engine/encoding-terms.d.mts +105 -0
  83. package/types/engine/encoding.d.mts +155 -0
  84. package/types/engine/helpers.d.mts +1 -1
  85. package/types/engine/index.d.mts +4 -0
  86. package/types/engine/language-terms.d.mts +1 -1
  87. package/types/engine/{logographic.d.mts → language-writings.d.mts} +6 -6
  88. package/types/engine/markup-validation.d.mts +1 -1
  89. package/types/engine/pdf-render.d.mts +1 -1
  90. package/types/engine/roman-characters.d.mts +2 -2
  91. package/types/engine/roman-run.d.mts +101 -0
  92. package/types/engine/roman-verse.d.mts +23 -0
  93. package/types/engine/run-rows.d.mts +13 -20
  94. package/types/engine/script-emphasis-terms.d.mts +2 -0
  95. package/types/engine/script-emphasis.d.mts +1 -1
  96. package/types/engine/script-font-delivery.d.mts +2 -3
  97. package/types/engine/script-fonts.d.mts +5 -0
  98. package/types/engine/site-preview.d.mts +1 -1
  99. package/types/engine/word-inflections.d.mts +25 -0
  100. package/engine/map-lexicon.mjs +0 -1045
  101. package/engine/text-maps.mjs +0 -654
  102. package/engine/written-in.mjs +0 -204
  103. package/types/engine/map-lexicon.d.mts +0 -330
  104. package/types/engine/text-maps.d.mts +0 -189
  105. package/types/engine/written-in.d.mts +0 -76
@@ -12,80 +12,31 @@
12
12
  */
13
13
 
14
14
  /**
15
- * **Text marked with a language, a font and a mapping.** Three keys on the
16
- * attribute grammar spans, divs, verse fences and scroll fences already share,
17
- * each optional and independent of the others:
15
+ * Text marked with a language, font or encoding document on shared attributes.
16
+ * `enc=` resolves a document Address and infers its configured language and
17
+ * font; explicit selections must agree. `enc=default`, including an omitted
18
+ * selection, retains Roman text. A language alias or BCP 47 tag identifies
19
+ * text independently of conversion, and an installed font or generic family
20
+ * selects its face independently.
18
21
  *
19
- * ```markdown
20
- * [Daimela, helthos.]{lang=vyl}
21
- * [Zaron tholmeza]{lang=vyl font=semaron-kuriale map=semaron}
22
+ * Configured language notes supply vocabulary, Roman additions and emphasis.
23
+ * Encoded words read lexical native writings, complete parts and encoding
24
+ * mappings in that order. Source validation sees the complete logical run,
25
+ * including class-only emphasis and name children, while physical rendering
26
+ * preserves their wrappers. Children stating run keys make a separate choice.
27
+ * A native name's braces write the shared name controls.
22
28
  *
23
- * ::: {lang=vyl font=semaron-kuriale map=semaron}
24
- * Zaron tholmeza.
25
- * :::
26
- *
27
- * ```verse {form=epic lang=vyl font=semaron-kuriale map=semaron}
28
- * ```
29
- *
30
- * - **`lang=`** gives the language tag. It is a name declared in the
31
- * package's `languages`, which gives its tag, or otherwise a well-formed BCP
32
- * 47 tag, checked by form alone. A declared language whose note names its
33
- * script in `data.writtenIn` gives a run the map and face it leaves
34
- * unstated; see {@link module:engine/written-in}.
35
- * - **`font=`** is one of the installed faces, by its class without `hl-`
36
- * (`semaron-kuriale`), or `mono`, `serif` or `sans`.
37
- * - **`map=`** names a mapping note's `data.map`, which rewrites the text
38
- * before it is set; see {@link module:engine/text-maps}. Without it the text
39
- * is set as typed. A run's `font=` is one of the faces the map lists in its
40
- * `fonts`, whose own rows come before the shared ones.
41
- * - **`lang=` with `map=` reads the language's lexicon first**: a word whose
42
- * entry states a native form for the map is written by it, and the rows
43
- * spell every other word; see {@link module:engine/map-lexicon}.
44
- * - **A logographic map writes every word from the lexicon**: by the word's
45
- * own writing or its parts', and a word with neither, or whose form stands
46
- * for entries written differently, is a finding at the word, once per run;
47
- * see {@link module:engine/logographic}.
48
- * - **Every word of a run with `lang=` and `map=` is a word of its language**
49
- * where the language's `note` names its language note: a lexicon form, a
50
- * word of a name its own register lists, or a regular form of a lexicon
51
- * form. Any other
52
- * word is a finding at its first character, once per run; see
53
- * {@link module:engine/run-words}.
54
- * - **A run inside a span, div or verse fence inherits its keys**, and its
55
- * own keys override them. A span switching to a different language selects
56
- * that language's script defaults and its own explicit font and map. A span
57
- * in the same language keeps the surrounding script choices.
58
- * - **Text with a font, a map or a declared non-English language is plain.** A span or a
59
- * verse holds no emphasis, image, footnote, wikilink, code or HTML, and no
60
- * span but a marked one. A standalone resolved reference sets only its
61
- * label; links embedded in longer native prose remain forbidden. A div
62
- * holds paragraphs and line breaks only.
63
- * - **A non-English span holds no bold or italic markup**, including in a
64
- * script face. English tags and configured names resolving to English
65
- * retain their emphasis under the font's rules. Escaped delimiters are text.
66
- * - **Other text set in a face takes `*…*` and `**…**`**, set by the convention its
67
- * language's note states, else its face's script note, else plainly; see
68
- * {@link module:engine/script-emphasis}. A language that marks emphasis
69
- * with a word makes either a finding at the emphasis.
70
- * The text is read as written, so `[`, `]`, `"`, `<<`, `>>`, `!` and
71
- * `@#$%^&` are characters, and need no escaping. A real language's tag alone
72
- * marks text and leaves its Markdown alone, subject to the non-English span
73
- * rule. Verse indentation periods remain layout rather than mapped text.
74
- * - **A face draws only what it lists.** After the map, a character that is
75
- * neither whitespace nor in the face's `codepoints` is a finding, at the
76
- * source character it came from. `mono`, `serif` and `sans` are not checked.
77
- *
78
- * - **A scroll fence is set in a face whose script note states
79
- * `direction: vertical-rl`.** One that names no `font=` takes its
80
- * language's script, else the face of a map drawn in one; one that names no
81
- * face, or whose script is written in lines, is a finding. See {@link module:engine/content-scroll}.
82
- *
83
- * {@link scanRuns} reads the markup through {@link module:engine/content-spans.scanSpans},
84
- * {@link module:engine/content-blocks.scanBlocks},
85
- * {@link module:engine/content-verse.scanVerse} and
86
- * {@link module:engine/content-scroll.scanScrolls}, so what counts as a span, a
87
- * div or a fence is theirs to say.
29
+ * Plain configured-language runs restrict Markdown consistently across site,
30
+ * Foundry and book targets. Non-English spans admit no bold or italic markup;
31
+ * explicit emphasis classes follow the language's or script's convention.
32
+ * English text with the default encoding retains ordinary Markdown grammar.
33
+ * A selected font must draw every visible encoded output character.
88
34
  *
35
+ * Span, div, verse and scroll scanners retain original UTF-16 source offsets.
36
+ * Public diagnostics count Unicode code-point columns. A scroll's face must
37
+ * belong to a script written vertically, and parallel verse selects encoding
38
+ * on each reading rather than the fence. Every native content line retains
39
+ * its line boundary before a stanza or canto.
89
40
  * @module
90
41
  */
91
42
 
@@ -105,25 +56,25 @@ import {
105
56
  fontProblem,
106
57
  languageRecordsByName,
107
58
  lexiconsFromRecords,
108
- mappingFor,
109
- uncapitalisedMessage,
110
- unwrittenMessage,
111
- } from "./map-lexicon.mjs";
59
+ encodingFor,
60
+ } from "./encoding-lexicon.mjs";
112
61
  import { unknownWordMessage, unknownWords, wordBooksFromRecords } from "./run-words.mjs";
113
62
  import { metreBooksFromRecords, metreFindings, metresFromRecords } from "./verse-metre.mjs";
114
- import { applyMap, mapsFromRecords } from "./text-maps.mjs";
115
- import { writtenInOf } from "./written-in.mjs";
63
+ import { encodeWithLanguage } from "./encoding-language.mjs";
64
+ import { encodingsFromRecords, encodingAddress } from "./encoding-notes.mjs";
116
65
  import { romanInventoriesOf, romanTextProblems } from "./roman-characters.mjs";
117
- import { isSettingScriptCodePoint } from "./content-charset.mjs";
66
+ import { plainRomanRun } from "./roman-run.mjs";
67
+ import { plainRomanVerse } from "./roman-verse.mjs";
68
+ import { encodeText } from "./encoding.mjs";
118
69
  import { WIKILINK } from "./wikilink-syntax.mjs";
119
70
  import { matchAllOutsideCode } from "./code-fences.mjs";
120
71
  import { parseExtensionAttributes } from "./extension-attributes.mjs";
121
72
 
122
73
  /** The keys this module reads, which no construct passes through as written. */
123
- export const RUN_KEYS = Object.freeze(["lang", "font", "map"]);
74
+ export const RUN_KEYS = Object.freeze(["lang", "font", "enc"]);
124
75
 
125
76
  /** Explicit opt-in to native-script presentation on this construct. */
126
- export const NATIVEFONT_CLASS = "nativefont";
77
+ const NATIVEFONT_CLASS = "nativefont";
127
78
 
128
79
  /** The generic families `font=` may name, each with the class that sets it. */
129
80
  export const GENERIC_FONTS = Object.freeze({
@@ -139,22 +90,22 @@ const CONFIG = "package-build.config.yaml";
139
90
  * What a run is checked against.
140
91
  *
141
92
  * @typedef {object} RunContext
93
+ * @property {string} [contentPackage] - The citing content package.
142
94
  * @property {Readonly<Record<string, {tag: string}>>} [languages] - The
143
95
  * configured languages.
144
96
  * @property {ReadonlyMap<string, import("./script-fonts.mjs").Face>} [faces] -
145
97
  * The installed faces by slug, from
146
98
  * {@link module:engine/script-fonts.resolveScriptFonts}.
147
- * @property {ReadonlyMap<string, import("./text-maps.mjs").TextMap>} [maps] -
148
- * The mapping notes' maps by name, from
149
- * {@link module:engine/text-maps.mapsFromRecords}.
150
- * @property {ReadonlyMap<string, import("./written-in.mjs").WrittenIn>} [writtenIn] -
151
- * The script each configured language's note names, by language name, from
152
- * {@link module:engine/written-in.writtenInOf}.
153
- * @property {ReadonlyMap<string, import("./map-lexicon.mjs").Lexicon>} [lexicons] -
99
+ * @property {ReadonlyMap<string, ReturnType<import("./encoding.mjs").compileEncoding>>} [encodings] -
100
+ * The compiled encoding documents, keyed by canonical and local Addresses.
101
+ * @property {ReadonlyArray<{file?: string, line?: number, column?: number, message: string}>} [encodingFindings] - Encoding-document or font-code findings.
102
+ * @property {ReadonlyMap<string, {data: object, codes: Record<string,string>}>} [encodingDefinitions] - Serialized encoding documents and their selected font codes.
103
+ * @property {ReadonlyMap<string, Record<string, any>>} [languageRecords] - The configured language notes.
104
+ * @property {ReadonlyMap<string, import("./encoding-lexicon.mjs").Lexicon>} [lexicons] -
154
105
  * Each configured language's lexicon by name, from
155
- * {@link module:engine/map-lexicon.lexiconsFromRecords}.
106
+ * {@link module:engine/encoding-lexicon.lexiconsFromRecords}.
156
107
  * @property {ReadonlyMap<string, import("./run-words.mjs").WordBook>} [wordBooks] -
157
- * What each configured language's mapped runs are checked against, from
108
+ * What each configured language's encoded runs are checked against, from
158
109
  * {@link module:engine/run-words.wordBooksFromRecords}.
159
110
  * @property {ReadonlyMap<string, import("./metre-declaration.mjs").Metre>} [metres] -
160
111
  * The metres the `doc` notes declare, by name.
@@ -175,11 +126,9 @@ const CONFIG = "package-build.config.yaml";
175
126
  */
176
127
 
177
128
  /**
178
- * One span, div, verse fence or scroll fence carrying `lang=`, `font=` or
179
- * `map=`. Its `lang`, `font` and `map` are the ones it is set with: its own,
180
- * else inherited language or an ordinary prose family. Native map/face
181
- * defaults are selected only by this construct's explicit `.nativefont`
182
- * opt-in; marked children make their own presentation choice.
129
+ * One span, div, verse fence or scroll fence carrying run attributes.
130
+ * Values combine explicit choices, class-only inheritance, encoding-document
131
+ * language/font defaults and the Roman default encoding.
183
132
  *
184
133
  * @typedef {object} Run
185
134
  * @property {"span"|"block"|"verse"|"scroll"} kind - Which construct.
@@ -187,32 +136,30 @@ const CONFIG = "package-build.config.yaml";
187
136
  * @property {string|undefined} tag - The BCP 47 tag it resolves to; `undefined`
188
137
  * when it resolves to none.
189
138
  * @property {string|undefined} font - The font as written.
190
- * @property {string|undefined} map - The map as written.
139
+ * @property {string|undefined} enc - The resolved document Address, or default for Roman text.
191
140
  * @property {number} line - 1-based line of the construct's opening.
192
141
  * @property {number} column - 1-based UTF-16 column of its opening.
193
142
  * @property {number} start - A span's offset of `[`; a div's or fence's
194
143
  * 0-based opening line.
195
144
  * @property {number} end - A span's offset past its attributes; a div's or
196
145
  * fence's 0-based closing line.
197
- * @property {boolean} [nativefont] - True only for an explicit native opt-in
198
- * with its own language; retained by index records for deferred defaults.
199
146
  */
200
147
 
201
148
  /** @type {WeakMap<object, RunContext>} */
202
149
  const contexts = new WeakMap();
203
150
 
204
151
  /** @type {WeakMap<object, WeakMap<object, RunContext>>} */
205
- const withMaps = new WeakMap();
152
+ const withEncodings = new WeakMap();
206
153
 
207
154
  /**
208
- * The context a resolved configuration gives, and the maps an index holds.
155
+ * The context a resolved configuration gives, and the encodings an index holds.
209
156
  *
210
- * @param {{languages?: object, rootDir?: string}|undefined} config - The
157
+ * @param {{languages?: object, rootDir?: string, contentPackage?:string, paths?:{content?:string}}|undefined} config - The
211
158
  * resolved configuration.
212
159
  * @param {Iterable<Record<string, any>>} [records] - The content index's
213
- * records, whose mapping notes give the maps. Without them no map is known.
160
+ * records, whose language notes supply lexical writings and script choices.
214
161
  * @returns {RunContext} Its languages, the faces installed where it sits, and
215
- * the maps. A fonts package that cannot be read gives no faces; a run naming
162
+ * the encodings. A fonts package that cannot be read gives no faces; a run naming
216
163
  * one is reported.
217
164
  */
218
165
  export function runContext(config, records) {
@@ -220,14 +167,19 @@ export function runContext(config, records) {
220
167
  let base = contexts.get(config);
221
168
  if (!base) {
222
169
  let faces;
170
+ let fonts;
223
171
  if (config.rootDir) {
224
172
  try {
225
- faces = resolveScriptFonts(config.rootDir)?.faces;
173
+ fonts = resolveScriptFonts(config.rootDir);
174
+ faces = fonts?.faces;
226
175
  } catch {
227
176
  faces = undefined;
228
177
  }
229
178
  }
230
179
  base = Object.freeze({
180
+ fonts,
181
+ encodings: new Map(),
182
+ encodingFindings: [],
231
183
  languages: config.languages ?? {},
232
184
  faces: faces ?? new Map(),
233
185
  contentPackage: config.contentPackage,
@@ -235,16 +187,19 @@ export function runContext(config, records) {
235
187
  contexts.set(config, base);
236
188
  }
237
189
  if (!records || typeof records !== "object") return base;
238
- let byRecords = withMaps.get(config);
239
- if (!byRecords) withMaps.set(config, (byRecords = new WeakMap()));
190
+ let byRecords = withEncodings.get(config);
191
+ if (!byRecords) withEncodings.set(config, (byRecords = new WeakMap()));
240
192
  const known = byRecords.get(records);
241
193
  if (known) return known;
242
- const maps = mapsFromRecords(records);
194
+ const loaded = encodingsFromRecords(records, config, base.fonts);
195
+ const encodings = loaded.encodings;
243
196
  const languageRecords = languageRecordsByName(records, config);
244
197
  const context = Object.freeze({
245
198
  ...base,
246
- maps,
247
- writtenIn: writtenInOf(languageRecords, maps),
199
+ encodings,
200
+ encodingDefinitions: loaded.definitions,
201
+ encodingFindings: loaded.findings,
202
+ languageRecords,
248
203
  romanInventories: romanInventoriesOf(languageRecords, config.languages),
249
204
  lexicons: lexiconsFromRecords(records, config),
250
205
  wordBooks: wordBooksFromRecords(records, config),
@@ -595,28 +550,6 @@ function emphasisDelimiters(text, holes = new Map()) {
595
550
  return delimiters;
596
551
  }
597
552
 
598
- /**
599
- * The parts of ranges of text that no delimiter covers.
600
- *
601
- * @param {Array<[number, number]>} parts - The ranges, as offsets.
602
- * @param {readonly Delimiter[]} delimiters - Delimiters, by absolute offset.
603
- * @returns {Array<[number, number]>} The ranges, split around each delimiter.
604
- */
605
- function outsideDelimiters(parts, delimiters) {
606
- if (!delimiters.length) return parts;
607
- const out = [];
608
- for (const [from, to] of parts) {
609
- let at = from;
610
- for (const delimiter of delimiters) {
611
- if (delimiter.offset < at || delimiter.offset >= to) continue;
612
- if (delimiter.offset > at) out.push([at, delimiter.offset]);
613
- at = delimiter.offset + delimiter.length;
614
- }
615
- if (at < to) out.push([at, to]);
616
- }
617
- return out;
618
- }
619
-
620
553
  /** A line split into its indentation, its text, a trailing break and its trailing space. */
621
554
  const LINE_PARTS = /^(\s*)(.*?)(\\?)(\s*)$/;
622
555
 
@@ -630,7 +563,7 @@ const LINE_PARTS = /^(\s*)(.*?)(\\?)(\s*)$/;
630
563
  * @returns {string} The text, safe in a paragraph.
631
564
  */
632
565
  function escapeRoman(line, lineStart = true) {
633
- const escaped = line.replace(/[\\`*_[\]<>!&#~|{}]/g, "\\$&");
566
+ const escaped = line.replace(/[\\`*_[\]<>"'!&#~|{}-]/g, "\\$&");
634
567
  return lineStart ?
635
568
  escaped.replace(/^([-+=:])/, "\\$1").replace(/^(\d+)([.)])/, "$1\\$2")
636
569
  : escaped;
@@ -660,19 +593,47 @@ function unescapeRoman(core) {
660
593
  return { text, offsets };
661
594
  }
662
595
 
596
+ /** Apply a selected encoding, retaining source text only for recoverable rendering. */
597
+ function applyEncoding(encoding, text, options = {}) {
598
+ if (!encoding)
599
+ return {
600
+ text,
601
+ pieces: [...text].map((char, index) => ({
602
+ from: char,
603
+ to: char,
604
+ at: index,
605
+ row: false,
606
+ })),
607
+ };
608
+ const result = encodeWithLanguage(encoding, text, options);
609
+ return {
610
+ text: result.findings.length ? text : result.text,
611
+ pieces: [
612
+ ...result.pieces.map((one) => ({ ...one, at: one.start, row: Boolean(one.row) })),
613
+ ...result.findings.map((one) => ({
614
+ from: text.slice(one.offset, one.offset + 1),
615
+ to: "",
616
+ at: one.offset,
617
+ row: false,
618
+ problem: one.message,
619
+ })),
620
+ ],
621
+ };
622
+ }
623
+
663
624
  /**
664
- * Set a piece of a run's text: an author's escapes removed, the map applied,
625
+ * Set a piece of a run's text: an author's escapes removed, the enc applied,
665
626
  * and the characters Markdown would read escaped again. Every line is set on
666
627
  * its own, so the line count holds.
667
628
  *
668
629
  * @param {string} raw - The text as written.
669
- * @param {import("./map-lexicon.mjs").Mapping|undefined} mapping - The run's
670
- * map, face and lexicon.
630
+ * @param {ReturnType<import("./encoding-lexicon.mjs").encodingFor>|undefined} selection - The run's
631
+ * enc, face and lexicon.
671
632
  * @param {boolean} lineStart - Whether `raw` starts its line.
672
- * @returns {{text: string, pieces: Array<{offset: number, from: string, to: string, row: boolean, native?: boolean, uncapitalised?: string, unwritten?: object}>}}
633
+ * @returns {{text: string, pieces: Array<{offset: number, from: string, to: string, row: boolean, native?: boolean, problem?: string}>}}
673
634
  * The set text, and each piece of it with the offset in `raw` it came from.
674
635
  */
675
- function setText(raw, mapping, lineStart) {
636
+ function setText(raw, selection, lineStart) {
676
637
  const out = [];
677
638
  const pieces = [];
678
639
  let base = 0;
@@ -683,26 +644,22 @@ function setText(raw, mapping, lineStart) {
683
644
  if (core === "") out.push(line);
684
645
  else {
685
646
  const plain = unescapeRoman(core);
686
- const mapped = applyMap(mapping?.map, plain.text, {
687
- font: mapping?.font,
688
- native: mapping?.native,
689
- writing: mapping?.writing,
647
+ const encoded = applyEncoding(selection?.encoding, plain.text, {
648
+ font: selection?.font,
649
+ lexicon: selection?.lexicon,
690
650
  });
691
- for (const piece of mapped.pieces)
651
+ for (const piece of encoded.pieces)
692
652
  pieces.push({
693
653
  offset: base + lead.length + (plain.offsets[piece.at] ?? 0),
694
654
  from: piece.from,
695
655
  to: piece.to,
696
656
  row: piece.row,
697
657
  ...(piece.native ? { native: true } : {}),
698
- ...(piece.uncapitalised === undefined ?
699
- {}
700
- : { uncapitalised: piece.uncapitalised }),
701
- ...(piece.unwritten === undefined ? {} : { unwritten: piece.unwritten }),
658
+ ...(piece.problem === undefined ? {} : { problem: piece.problem }),
702
659
  });
703
660
  out.push(
704
661
  lead +
705
- escapeRoman(mapped.text, index > 0 || lineStart || lead !== "") +
662
+ escapeRoman(encoded.text, index > 0 || lineStart || lead !== "") +
706
663
  breakMark +
707
664
  trail,
708
665
  );
@@ -714,21 +671,20 @@ function setText(raw, mapping, lineStart) {
714
671
 
715
672
  /**
716
673
  * Set a stretch of a scroll fence's text: the author's escapes removed and the
717
- * map applied to all of it, its spaces included, and nothing escaped, since
674
+ * enc applied to all of it, its spaces included, and nothing escaped, since
718
675
  * the fence's body is raw.
719
676
  *
720
677
  * @param {string} raw - The text, on one line.
721
- * @param {import("./map-lexicon.mjs").Mapping|undefined} mapping - The run's
722
- * map, face and lexicon.
678
+ * @param {ReturnType<import("./encoding-lexicon.mjs").encodingFor>|undefined} selection - The run's
679
+ * enc, face and lexicon.
723
680
  * @returns {{text: string, pieces: Array<{from: string, to: string}>}} The set
724
681
  * text, and each piece of it.
725
682
  */
726
- function setScrollText(raw, mapping) {
683
+ function setScrollText(raw, selection) {
727
684
  const plain = unescapeRoman(raw);
728
- return applyMap(mapping?.map, plain.text, {
729
- font: mapping?.font,
730
- native: mapping?.native,
731
- writing: mapping?.writing,
685
+ return applyEncoding(selection?.encoding, plain.text, {
686
+ font: selection?.font,
687
+ lexicon: selection?.lexicon,
732
688
  });
733
689
  }
734
690
 
@@ -807,8 +763,8 @@ const BLOCK_WHAT = Object.freeze({
807
763
  * @property {boolean} plain - Whether its text is set.
808
764
  * @property {import("./script-fonts.mjs").Face|undefined} face - Its face.
809
765
  * @property {string|undefined} generic - Its generic family's class.
810
- * @property {import("./text-maps.mjs").TextMap|undefined} textMap - Its map.
811
- * @property {import("./map-lexicon.mjs").Mapping|undefined} mapping - Its map,
766
+ * @property {ReturnType<import("./encoding.mjs").compileEncoding>|undefined} encoding - Its enc.
767
+ * @property {ReturnType<import("./encoding-lexicon.mjs").encodingFor>|undefined} selection - Its enc,
812
768
  * face and lexicon, which its text is set by.
813
769
  * @property {Map<number, number>} holes - The marked spans inside it.
814
770
  * @property {[number, number]|undefined} body - A div's or fence's body, as
@@ -822,6 +778,10 @@ const BLOCK_WHAT = Object.freeze({
822
778
  * What its emphasis is set by: its language's convention, else its face's
823
779
  * script's; `undefined` sets it plain.
824
780
  * @property {Delimiter[]} emphasis - The emphasis it holds, by absolute offset.
781
+ * @property {boolean} [checkRoman] - Its inline source structure permits Roman
782
+ * inventory validation, independently of encoding and face resolution.
783
+ * @property {boolean} [checkText] - Its selection and source structure permit
784
+ * native encoding and output glyph validation.
825
785
  * @property {boolean} [unrolled] - A scroll fence whose opening has a finding,
826
786
  * which is left as written.
827
787
  */
@@ -847,10 +807,10 @@ function wordsMessage(lang) {
847
807
  * @returns {import("./script-emphasis.mjs").Emphasis|undefined} The conventions.
848
808
  */
849
809
  function conventionsOf({ face, languageEmphasis }, ctx) {
850
- if (!face || languageEmphasis === "words") return undefined;
810
+ if (languageEmphasis === "words") return undefined;
851
811
  if (languageEmphasis === "rubric" || languageEmphasis === "overline")
852
812
  return { em: languageEmphasis, strong: languageEmphasis };
853
- return ctx.emphases?.get(face.slug);
813
+ return face ? ctx.emphases?.get(face.slug) : undefined;
854
814
  }
855
815
 
856
816
  /**
@@ -860,7 +820,7 @@ function conventionsOf({ face, languageEmphasis }, ctx) {
860
820
  * @param {RunContext} ctx - The names.
861
821
  * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session
862
822
  * scanner projections.
863
- * @returns {{runs: Run[], details: RunDetail[], errors: Array<{line: number, column: number, message: string}>, warnings: Array<{line: number, column: number, message: string}>, spans: object[], divs: Map<number, object>, poems: Map<number, object>}}
823
+ * @returns {{runs: Run[], details: RunDetail[], errors: Array<{file?: string, line: number, column: number, message: string}>, warnings: Array<{line: number, column: number, message: string}>, spans: object[], divs: Map<number, object>, poems: Map<number, object>}}
864
824
  * What {@link scanRuns} returns, and what {@link renderRuns} rewrites by.
865
825
  */
866
826
  function analyseRuns(source, ctx, syntax) {
@@ -869,7 +829,12 @@ function analyseRuns(source, ctx, syntax) {
869
829
  const starts = lineStarts(text);
870
830
  const languages = ctx.languages ?? {};
871
831
  const faces = ctx.faces ?? new Map();
872
- const maps = ctx.maps ?? new Map();
832
+ const encodings = ctx.encodings ?? new Map();
833
+ const encodingUnavailable = ctx.encodingFindings?.[0];
834
+ const encodingNamed = (value) =>
835
+ value === "default" ? undefined : (
836
+ (encodings.get(value) ?? encodings.get(encodingAddress(value, ctx.contentPackage)))
837
+ );
873
838
  const errors = [];
874
839
  const warnings = [];
875
840
  const footnotes = new Set(
@@ -944,7 +909,7 @@ function analyseRuns(source, ctx, syntax) {
944
909
  line0: first,
945
910
  where: { line: first + 1, column: 1 },
946
911
  }),
947
- `\`.nativefont\` is read on a span, a div, a verse fence or a scroll fence, not on a ${block.name} block`,
912
+ "`.nativefont` is not an accepted class; select an encoding with `enc=`",
948
913
  );
949
914
  for (const key of Object.keys(keys))
950
915
  report(
@@ -1066,6 +1031,7 @@ function analyseRuns(source, ctx, syntax) {
1066
1031
  (construct) =>
1067
1032
  construct.kind === "scroll" ||
1068
1033
  construct.own.classes.includes(NATIVEFONT_CLASS) ||
1034
+ construct.own.classes.some((name) => name === "emph" || name === "strong") ||
1069
1035
  Object.keys(construct.keys).length ||
1070
1036
  around(construct).some((container) => Object.keys(container.keys).length),
1071
1037
  );
@@ -1077,43 +1043,25 @@ function analyseRuns(source, ctx, syntax) {
1077
1043
  : isLanguageTag(lang) ? lang
1078
1044
  : undefined;
1079
1045
 
1080
- const native = (construct) => construct.own.classes.includes(NATIVEFONT_CLASS);
1046
+ const native = (construct) =>
1047
+ construct.keys.enc !== undefined && construct.keys.enc !== "default";
1081
1048
 
1082
1049
  const faceNames = () => [...faces.keys(), ...Object.keys(GENERIC_FONTS)];
1083
1050
 
1084
1051
  /** Check the keys a construct states itself. */
1085
1052
  const checkOwn = (construct) => {
1086
- const { lang, font, map } = construct.keys;
1053
+ const { lang, font, enc } = construct.keys;
1087
1054
  const { at } = construct;
1088
- if (native(construct) && lang === undefined)
1055
+ if (construct.own.classes.includes(NATIVEFONT_CLASS))
1089
1056
  report(
1090
1057
  nativeAt(construct),
1091
- "`.nativefont` requires an explicit `lang=` on the same construct",
1058
+ "`.nativefont` is not an accepted class; select an encoding with `enc=`",
1092
1059
  );
1093
1060
  if (native(construct) && construct.kind === "verse" && hasColumns(construct.node))
1094
1061
  report(
1095
- nativeAt(construct),
1096
- "a verse with cols>1 opts individual runs into `.nativefont`, not the fence",
1062
+ construct.at("enc", "value"),
1063
+ "a verse with cols>1 selects encoding on individual runs, not the fence",
1097
1064
  );
1098
- if (!native(construct)) {
1099
- if (construct.kind === "scroll")
1100
- report(
1101
- construct.where,
1102
- "a scroll requires `.nativefont` and an explicit `lang=` on the same construct",
1103
- );
1104
- else {
1105
- if (font !== undefined && faces.has(font))
1106
- report(
1107
- at("font", "value"),
1108
- `native \`font=${font}\` requires \`.nativefont\` and an explicit \`lang=\` on the same construct`,
1109
- );
1110
- if (map !== undefined && maps.has(map))
1111
- report(
1112
- at("map", "value"),
1113
- `\`map=${map}\` requires \`.nativefont\` and an explicit \`lang=\` on the same construct`,
1114
- );
1115
- }
1116
- }
1117
1065
 
1118
1066
  if (lang !== undefined && !Object.hasOwn(languages, lang)) {
1119
1067
  if (isLanguageTag(lang)) {
@@ -1146,45 +1094,71 @@ function analyseRuns(source, ctx, syntax) {
1146
1094
  (near ? `; did you mean \`${near}\`?` : ""),
1147
1095
  );
1148
1096
  }
1149
- if (map !== undefined && !maps.has(map)) {
1150
- const near = nearest(map, [...maps.keys()]);
1097
+ if (enc !== undefined && enc !== "default" && !encodingNamed(enc) && !encodingUnavailable) {
1098
+ const near = nearest(enc, [...encodings.keys()]);
1151
1099
  report(
1152
- at("map", "value"),
1153
- `\`${map}\` is not a map: \`map=\` names the \`data.map.name\` of a mapping note` +
1100
+ at("enc", "value"),
1101
+ `\`${enc}\` does not resolve to an encoding document` +
1154
1102
  (near ? `; did you mean \`${near}\`?` : ""),
1155
1103
  );
1156
1104
  }
1157
1105
  };
1158
1106
 
1159
1107
  const nativeDefaults = (construct, keys) => {
1160
- const defaults = ctx.writtenIn?.get(keys.lang);
1161
- for (const key of ["map", "font"])
1162
- if (keys[key] === undefined && defaults?.[key] !== undefined) keys[key] = defaults[key];
1163
- if (keys.font === undefined)
1164
- report(
1165
- nativeAt(construct),
1166
- `\`.nativefont\` has no default \`font=\` for \`${keys.lang}\`; state \`font=\` explicitly`,
1167
- );
1168
- if (keys.map !== undefined && !maps.has(keys.map))
1169
- report(
1170
- nativeAt(construct),
1171
- `native default \`map=${keys.map}\` is unavailable; state \`map=\` explicitly`,
1172
- );
1108
+ if (encodingUnavailable) return;
1109
+ const encoding = encodings.get(keys.enc);
1110
+ if (!encoding) return;
1173
1111
  if (
1174
- keys.font !== undefined &&
1175
- !faces.has(keys.font) &&
1176
- !Object.hasOwn(GENERIC_FONTS, keys.font)
1112
+ encoding.language !== undefined &&
1113
+ keys.lang !== undefined &&
1114
+ keys.lang !== encoding.language &&
1115
+ tagOf(keys.lang)?.toLowerCase() !== tagOf(encoding.language)?.toLowerCase()
1177
1116
  )
1178
1117
  report(
1179
- nativeAt(construct),
1180
- `native default \`font=${keys.font}\` is unavailable; state \`font=\` explicitly`,
1118
+ construct.at(construct.keys.lang !== undefined ? "lang" : "enc", "value"),
1119
+ `Encoding \`${encoding.name}\` writes language \`${encoding.language}\`, not \`${keys.lang}\``,
1181
1120
  );
1121
+ if (!keys.font || (!faces.has(keys.font) && !Object.hasOwn(GENERIC_FONTS, keys.font)))
1122
+ report(construct.at("enc", "value"), `Encoding font \`${keys.font}\` is unavailable`);
1123
+ };
1124
+
1125
+ // A class-only child marks the parent's text. An explicit run key makes
1126
+ // a separate presentation choice, including an explicit language switch.
1127
+ const presentations = new Map();
1128
+ const presentationOf = (construct) => {
1129
+ if (presentations.has(construct)) return presentations.get(construct);
1130
+ const ancestors = around(construct);
1131
+ const inherits = construct.kind === "span" && !Object.keys(construct.keys).length;
1132
+ const parent = inherits ? ancestors.at(-1) : undefined;
1133
+ const inherited = parent ? presentationOf(parent) : undefined;
1134
+ const keys = inherited ? { ...inherited.keys } : {};
1135
+ if (!inherited)
1136
+ for (const ancestor of ancestors) {
1137
+ const inheritedKeys = presentationOf(ancestor).keys;
1138
+ if (inheritedKeys.lang !== undefined) keys.lang = inheritedKeys.lang;
1139
+ if (!native(ancestor) && ancestor.keys.font !== undefined)
1140
+ keys.font = ancestor.keys.font;
1141
+ }
1142
+ if (native(construct)) delete keys.font;
1143
+ Object.assign(keys, construct.keys);
1144
+ keys.enc ??= "default";
1145
+ const selected = encodingNamed(keys.enc);
1146
+ if (selected) {
1147
+ keys.enc = selected.name;
1148
+ keys.lang ??= selected.language;
1149
+ keys.font ??= selected.font ?? selected.fonts[0];
1150
+ } else if (!inherits && keys.font === undefined && ancestors.some(native)) {
1151
+ keys.font = "serif";
1152
+ }
1153
+ const result = { keys };
1154
+ presentations.set(construct, result);
1155
+ return result;
1182
1156
  };
1183
1157
 
1184
1158
  const rawReferences = [];
1185
1159
  // Raw wikilinks belong to the resolver, not the span rewriter. Validate
1186
1160
  // their own control attributes with the same grammar before resolution,
1187
- // without mapping a destination or consuming the trailing attributes.
1161
+ // without selection a destination or consuming the trailing attributes.
1188
1162
  for (const match of matchAllOutsideCode(text, new RegExp(WIKILINK.source, "g"))) {
1189
1163
  const close = match[0].indexOf("]]") + 1;
1190
1164
  const from = match.index + close + 2;
@@ -1204,41 +1178,47 @@ function analyseRuns(source, ctx, syntax) {
1204
1178
  };
1205
1179
  const unchecked = errors.length;
1206
1180
  checkOwn(construct);
1181
+ const keys = presentationOf(construct).keys;
1207
1182
  const bar = match[1].indexOf("|");
1208
1183
  if (bar >= 0 && errors.length === unchecked) {
1209
- const ancestors = around(construct);
1210
- const inherited = ancestors.filter((parent) => parent.keys.lang !== undefined).at(-1)
1211
- ?.keys.lang;
1212
1184
  rawReferences.push({
1213
- lang: construct.keys.lang ?? inherited,
1185
+ lang: keys.lang,
1186
+ font: keys.font,
1214
1187
  from: match.index + 2 + bar + 1,
1215
1188
  to: match.index + close - 1,
1216
1189
  where: construct.where,
1217
1190
  construct,
1218
1191
  });
1219
1192
  }
1220
- if (errors.length !== unchecked || !native(construct)) continue;
1221
- const keys = { ...construct.keys };
1193
+ if (errors.length !== unchecked || keys.enc === "default" || !encodingNamed(keys.enc))
1194
+ continue;
1222
1195
  nativeDefaults(construct, keys);
1223
- if (errors.length !== unchecked || keys.map === undefined || keys.font === undefined)
1196
+ if (errors.length !== unchecked || keys.enc === undefined || keys.font === undefined)
1224
1197
  continue;
1225
- const problem = fontProblem(maps.get(keys.map), keys.font);
1198
+ const problem = fontProblem(encodings.get(keys.enc), keys.font);
1226
1199
  if (problem)
1227
1200
  report(
1228
- construct.at(construct.keys.map !== undefined ? "map" : "font", "value"),
1201
+ construct.at(construct.keys.font !== undefined ? "font" : "enc", "value"),
1229
1202
  problem,
1230
1203
  );
1231
1204
  }
1232
1205
 
1233
1206
  const verseMarkers = new Map();
1207
+ const verseContexts = new Map();
1208
+ const verseReadings = new Map();
1234
1209
  for (const poem of scanVerse(text).blocks) {
1210
+ for (let line = poem.start + 1; line < poem.end; line++) verseContexts.set(line, poem);
1235
1211
  for (let line = poem.start + 1; line < poem.end; line++)
1236
- if (STANZA_BREAK.test(lines[line].trim()))
1212
+ if (
1213
+ STANZA_BREAK.test(lines[line].trim()) ||
1214
+ /^---\s+\{\.canto\}$/.test(lines[line].trim())
1215
+ )
1237
1216
  verseMarkers.set(starts[line], starts[line] + lines[line].length);
1238
1217
  if (hasColumns(poem)) {
1239
1218
  for (const group of verseGroups(poem, lines)) {
1240
1219
  if (group.stanzaBreak) continue;
1241
1220
  for (const part of group.parts) {
1221
+ verseReadings.set(part.line, part);
1242
1222
  const start = starts[part.line];
1243
1223
  if (part.prefixLength)
1244
1224
  verseMarkers.set(
@@ -1317,117 +1297,13 @@ function analyseRuns(source, ctx, syntax) {
1317
1297
  }
1318
1298
  };
1319
1299
 
1320
- /**
1321
- * Report each character a face does not draw, once per run, at the source
1322
- * character it came from.
1323
- *
1324
- * @param {Array<[number, number]>} parts - The text to check, as offsets.
1325
- * @param {RunDetail} detail - The run.
1326
- */
1327
- const covered = (parts, detail) => {
1328
- const { face, textMap, mapping } = detail;
1329
- if (!face) return;
1330
- const said = new Set();
1331
- for (const [from, to] of parts) {
1332
- const lineStart = from === 0 || text[from - 1] === "\n";
1333
- for (const piece of setText(text.slice(from, to), mapping, lineStart).pieces) {
1334
- // A word with no writing is its own finding, not a face's.
1335
- if (piece.unwritten) continue;
1336
- for (const char of piece.to) {
1337
- const point = /** @type {number} */ (char.codePointAt(0));
1338
- if (/\s/u.test(char) || face.draws.has(point)) continue;
1339
- const key = piece.row ? `${piece.from}\u0000${point}` : char;
1340
- if (said.has(key)) continue;
1341
- said.add(key);
1342
- const where = positionOf(starts, from + piece.offset);
1343
- if (piece.native)
1344
- report(
1345
- where,
1346
- `\`${piece.from}\` is written ${codeName(point)} by the lexicon of ` +
1347
- `\`${detail.run.lang}\` for the map \`${textMap?.name}\`, which the font ` +
1348
- `\`${face.slug}\` does not draw`,
1349
- );
1350
- else if (piece.row)
1351
- report(
1352
- where,
1353
- `\`${piece.from}\` maps to ${codeName(point)} in the map \`${textMap?.name}\`, ` +
1354
- `which the font \`${face.slug}\` does not draw`,
1355
- );
1356
- else
1357
- report(
1358
- where,
1359
- `\`${char}\` (${codeName(point)}) is not drawn by the font \`${face.slug}\`` +
1360
- (textMap ?
1361
- `, and the map \`${textMap.name}\` has no row for it`
1362
- : ""),
1363
- );
1364
- }
1365
- }
1366
- }
1367
- };
1368
-
1369
- /**
1370
- * Report each capitalised word whose native form the rows cannot
1371
- * capitalise, at the word.
1372
- *
1373
- * @param {Array<[number, number]>} parts - The text to check, as offsets.
1374
- * @param {RunDetail} detail - The run.
1375
- */
1376
- const capitals = (parts, detail) => {
1377
- const { mapping } = detail;
1378
- if (!mapping?.native) return;
1379
- for (const [from, to] of parts) {
1380
- const lineStart = from === 0 || text[from - 1] === "\n";
1381
- for (const piece of setText(text.slice(from, to), mapping, lineStart).pieces)
1382
- if (piece.uncapitalised !== undefined)
1383
- report(
1384
- positionOf(starts, from + piece.offset),
1385
- uncapitalisedMessage(
1386
- piece.from,
1387
- piece.uncapitalised,
1388
- detail.run.lang,
1389
- mapping.map.name,
1390
- ),
1391
- );
1392
- }
1393
- };
1394
-
1395
- /**
1396
- * Report each word a logographic map gives no writing, once per run, at
1397
- * the word.
1398
- *
1399
- * @param {Array<[number, number]>} parts - The text to check, as offsets.
1400
- * @param {RunDetail} detail - The run.
1401
- */
1402
- const writings = (parts, detail) => {
1403
- const { mapping } = detail;
1404
- if (!mapping?.map.logographic) return;
1405
- const said = new Set();
1406
- for (const [from, to] of parts) {
1407
- const lineStart = from === 0 || text[from - 1] === "\n";
1408
- for (const piece of setText(text.slice(from, to), mapping, lineStart).pieces) {
1409
- if (piece.unwritten === undefined || said.has(piece.from)) continue;
1410
- said.add(piece.from);
1411
- report(
1412
- positionOf(starts, from + piece.offset),
1413
- unwrittenMessage(
1414
- piece.from,
1415
- piece.unwritten,
1416
- detail.run.lang,
1417
- mapping.map.name,
1418
- ),
1419
- );
1420
- }
1421
- }
1422
- };
1423
-
1424
1300
  /** The configured source inventory for either an alias or its BCP 47 tag. */
1425
1301
  const inventoryOf = (lang) =>
1426
1302
  lang === undefined ? undefined : (
1427
1303
  (ctx.romanInventories?.get(lang) ?? ctx.romanInventories?.get(lang.toLowerCase()))
1428
1304
  );
1429
1305
 
1430
- /** Check visible source before mapping or lexicon-native replacement. */
1306
+ /** Check visible source before selection or lexicon-native replacement. */
1431
1307
  const roman = (parts, detail) => {
1432
1308
  const inventory = inventoryOf(detail.run.lang);
1433
1309
  if (!inventory) return;
@@ -1439,60 +1315,16 @@ function analyseRuns(source, ctx, syntax) {
1439
1315
  return;
1440
1316
  }
1441
1317
  for (const [from, to] of parts) {
1442
- let base = from;
1443
- for (const line of text.slice(from, to).split("\n")) {
1444
- // A terminal unescaped backslash is a Markdown line break.
1445
- const breakMark =
1446
- /(?<!\\)(?:\\\\)*\\$/.test(line) && text[base + line.length] === "\n";
1447
- const plain = unescapeRoman(breakMark ? line.slice(0, -1) : line);
1448
- for (const problem of romanTextProblems(plain.text, inventory, (char) => {
1449
- const point = char.codePointAt(0);
1450
- return (
1451
- detail.run.nativefont === true &&
1452
- isSettingScriptCodePoint(point) &&
1453
- detail.face?.draws.has(point)
1454
- );
1455
- }))
1456
- report(
1457
- positionOf(starts, base + (plain.offsets[problem.offset] ?? 0)),
1458
- problem.message,
1459
- );
1460
- base += line.length + 1;
1461
- }
1462
- }
1463
- };
1464
-
1465
- /**
1466
- * Report each word of a mapped run that is not a word of its language, once
1467
- * per run, at its first character.
1468
- *
1469
- * @param {Array<[number, number]>} parts - The text to check, as offsets.
1470
- * @param {RunDetail} detail - The run.
1471
- */
1472
- const lexical = (parts, detail) => {
1473
- const { lang } = detail.run;
1474
- if (lang === undefined) return;
1475
- const book = ctx.wordBooks?.get(lang);
1476
- if (!book) return;
1477
- const said = new Set();
1478
- for (const [from, to] of parts) {
1479
- let base = from;
1480
- for (const line of text.slice(from, to).split("\n")) {
1481
- const plain = unescapeRoman(line);
1482
- for (const { offset, word } of unknownWords(plain.text, book, said))
1483
- report(
1484
- positionOf(starts, base + (plain.offsets[offset] ?? 0)),
1485
- unknownWordMessage(word, lang),
1486
- );
1487
- base += line.length + 1;
1488
- }
1318
+ const plain = plainRomanRun(text, { from, to });
1319
+ for (const problem of romanTextProblems(plain.text, inventory))
1320
+ report(positionOf(starts, plain.offsets[problem.offset] ?? from), problem.message);
1489
1321
  }
1490
1322
  };
1491
1323
 
1492
1324
  /**
1493
1325
  * Give a scroll fence the face it is set in, and report a fence that names
1494
1326
  * none or whose script is written in lines. A fence that names no face
1495
- * takes its map's, where the map is drawn in one.
1327
+ * takes its enc's, where the enc is drawn in one.
1496
1328
  *
1497
1329
  * @param {object} construct - The scroll fence.
1498
1330
  * @param {Record<string, string>} keys - Its run keys, given the face.
@@ -1503,7 +1335,7 @@ function analyseRuns(source, ctx, syntax) {
1503
1335
  if (ctx.directions.get(keys.font) === "vertical-rl") return;
1504
1336
  report(
1505
1337
  construct.keys.font !== undefined ? construct.at("font", "value")
1506
- : construct.keys.map !== undefined ? construct.at("map", "value")
1338
+ : construct.keys.enc !== undefined ? construct.at("enc", "value")
1507
1339
  : construct.keys.lang !== undefined ? construct.at("lang", "value")
1508
1340
  : construct.where,
1509
1341
  `a \`scroll\` fence sets a script written in columns, and the script of \`${keys.font}\` ` +
@@ -1516,69 +1348,52 @@ function analyseRuns(source, ctx, syntax) {
1516
1348
  for (const construct of active) {
1517
1349
  const unchecked = errors.length;
1518
1350
  checkOwn(construct);
1519
- // A key that names nothing is the finding; the text it would have set
1520
- // is not then checked against a face or a language it cannot reach.
1521
1351
  let named = errors.length === unchecked;
1522
- // Language and ordinary prose-family choices inherit. Native map/face
1523
- // presentation belongs to this construct alone, including same-language
1524
- // children, so a parallel Roman run never takes a native neighbour's face.
1525
- const ancestors = around(construct);
1526
- const keys = {};
1527
- for (const ancestor of ancestors) {
1528
- if (ancestor.keys.lang !== undefined) keys.lang = ancestor.keys.lang;
1529
- if (!native(ancestor) && Object.hasOwn(GENERIC_FONTS, ancestor.keys.font ?? ""))
1530
- keys.font = ancestor.keys.font;
1531
- }
1532
- if (native(construct)) delete keys.font;
1533
- Object.assign(keys, construct.keys);
1534
- const columnFence = construct.kind === "verse" && hasColumns(construct.node);
1535
- if (named && native(construct) && !columnFence) {
1352
+ const { keys } = presentationOf(construct);
1353
+ if (named && native(construct)) {
1536
1354
  nativeDefaults(construct, keys);
1537
1355
  named = errors.length === unchecked;
1538
- } else if (!native(construct) && keys.font === undefined && ancestors.some(native)) {
1539
- // Reset CSS/Typst inheritance from a surrounding native face.
1540
- keys.font = "serif";
1541
1356
  }
1542
- if (construct.kind === "scroll" && named) {
1543
- scrollFace(construct, keys);
1544
- named = errors.length === unchecked;
1545
- }
1546
- const { lang, font, map } = keys;
1547
- // A face the run's map is not for, once the face and the map are each
1357
+ const { lang, font, enc } = keys;
1358
+ // A face the run's enc is not for, once the face and the enc are each
1548
1359
  // known, at the construct that states either.
1549
1360
  const stated =
1550
1361
  native(construct) ||
1551
- construct.keys.map !== undefined ||
1362
+ construct.keys.enc !== undefined ||
1552
1363
  construct.keys.font !== undefined;
1553
1364
  if (
1554
1365
  named &&
1555
1366
  stated &&
1556
- map !== undefined &&
1557
- maps.has(map) &&
1367
+ enc !== undefined &&
1368
+ encodings.has(enc) &&
1558
1369
  font !== undefined &&
1559
1370
  (faces.has(font) || Object.hasOwn(GENERIC_FONTS, font))
1560
1371
  ) {
1561
- const problem = fontProblem(maps.get(map), font);
1372
+ const problem = fontProblem(encodings.get(enc), font);
1562
1373
  if (problem)
1563
1374
  report(
1564
- construct.at(construct.keys.map !== undefined ? "map" : "font", "value"),
1375
+ construct.at(construct.keys.font !== undefined ? "font" : "enc", "value"),
1565
1376
  problem,
1566
1377
  );
1567
1378
  }
1379
+ named = errors.length === unchecked;
1380
+ if (construct.kind === "scroll" && named) {
1381
+ scrollFace(construct, keys);
1382
+ named = errors.length === unchecked;
1383
+ }
1568
1384
  const run = {
1569
1385
  kind: construct.kind,
1570
1386
  lang,
1571
1387
  tag: tagOf(lang),
1572
1388
  font,
1573
- map,
1574
- ...(native(construct) && construct.keys.lang !== undefined ? { nativefont: true } : {}),
1389
+ enc,
1575
1390
  ...construct.where,
1576
1391
  };
1577
1392
  runs.push(run);
1578
1393
  const plain =
1579
1394
  inventoryOf(lang) !== undefined ||
1580
1395
  font !== undefined ||
1581
- map !== undefined ||
1396
+ (enc !== undefined && enc !== "default") ||
1582
1397
  (lang !== undefined &&
1583
1398
  Object.hasOwn(languages, lang) &&
1584
1399
  !/^en(?:-|$)/i.test(run.tag ?? ""));
@@ -1589,8 +1404,8 @@ function analyseRuns(source, ctx, syntax) {
1589
1404
  plain,
1590
1405
  face: font === undefined ? undefined : faces.get(font),
1591
1406
  generic: font === undefined ? undefined : GENERIC_FONTS[font],
1592
- textMap: map === undefined ? undefined : maps.get(map),
1593
- mapping: map === undefined ? undefined : mappingFor(ctx, { map, font, lang }),
1407
+ encoding: enc === undefined ? undefined : encodings.get(enc),
1408
+ selection: enc === undefined ? undefined : encodingFor(ctx, { enc, font, lang }),
1594
1409
  holes: new Map(),
1595
1410
  body: undefined,
1596
1411
  contentEnd: construct.kind === "span" ? construct.node.contentEnd : undefined,
@@ -1604,11 +1419,23 @@ function analyseRuns(source, ctx, syntax) {
1604
1419
  details.push(detail);
1605
1420
  const nonEnglishSpan =
1606
1421
  construct.kind === "span" && run.tag !== undefined && !/^en(?:-|$)/i.test(run.tag);
1422
+ const emphasis = construct.own.classes.filter(
1423
+ (name) => name === "emph" || name === "strong",
1424
+ );
1425
+ if (emphasis.length > 1)
1426
+ report(construct.where, "a span takes `.emph` or `.strong`, not both");
1427
+ else if (emphasis[0] === "strong" && nonEnglishSpan)
1428
+ report(
1429
+ construct.where,
1430
+ `a span in non-English language \`${lang}\` holds no \`.strong\` emphasis`,
1431
+ );
1432
+ else if (emphasis[0] === "emph" && detail.languageEmphasis === "words")
1433
+ report(construct.where, wordsMessage(lang));
1607
1434
  if (!plain && !nonEnglishSpan) continue;
1608
1435
  const marking = [
1609
1436
  ...(lang === undefined ? [] : [`lang=${lang}`]),
1610
1437
  ...(font === undefined ? [] : [`font=${font}`]),
1611
- ...(map === undefined ? [] : [`map=${map}`]),
1438
+ ...(enc === undefined ? [] : [`enc=${enc}`]),
1612
1439
  ].join(" ");
1613
1440
  const before = errors.length;
1614
1441
 
@@ -1639,19 +1466,8 @@ function analyseRuns(source, ctx, syntax) {
1639
1466
  }
1640
1467
  if (plain && errors.length === before)
1641
1468
  plainInline(inline, from, marking, detail, detail.holes);
1642
- if (errors.length === before) {
1643
- const parts = outsideDelimiters(
1644
- outsideHoles(from, to, detail.holes),
1645
- detail.emphasis,
1646
- );
1647
- roman(parts, detail);
1648
- if (named) {
1649
- lexical(parts, detail);
1650
- capitals(parts, detail);
1651
- writings(parts, detail);
1652
- covered(parts, detail);
1653
- }
1654
- }
1469
+ detail.checkRoman = errors.length === before;
1470
+ if (named && detail.checkRoman) detail.checkText = true;
1655
1471
  continue;
1656
1472
  }
1657
1473
 
@@ -1712,30 +1528,263 @@ function analyseRuns(source, ctx, syntax) {
1712
1528
  for (let line = first; line < last; line++)
1713
1529
  plainInline(lines[line], starts[line], marking, detail, detail.holes);
1714
1530
  }
1715
- if (errors.length === before) {
1716
- const parts = outsideDelimiters(outsideHoles(from, to, detail.holes), detail.emphasis);
1717
- roman(parts, detail);
1718
- if (named) {
1719
- lexical(parts, detail);
1720
- capitals(parts, detail);
1721
- writings(parts, detail);
1722
- covered(parts, detail);
1531
+ detail.checkRoman = errors.length === before;
1532
+ if (named && detail.checkRoman) detail.checkText = true;
1533
+ }
1534
+
1535
+ const boundsOf = (detail) =>
1536
+ detail.reference ??
1537
+ (detail.run.kind === "span" ? [detail.run.start + 1, detail.contentEnd] : detail.body);
1538
+ const parentOf = (detail) => {
1539
+ if (detail.run.kind !== "span" || Object.keys(detail.keys).length) return undefined;
1540
+ return details
1541
+ .filter(
1542
+ (parent) =>
1543
+ parent !== detail &&
1544
+ boundsOf(parent) &&
1545
+ boundsOf(parent)[0] <= detail.run.start &&
1546
+ boundsOf(parent)[1] >= detail.run.end,
1547
+ )
1548
+ .sort((a, b) => boundsOf(a)[1] - boundsOf(a)[0] - (boundsOf(b)[1] - boundsOf(b)[0]))[0];
1549
+ };
1550
+ const ownerOf = (detail) => {
1551
+ const parent = parentOf(detail);
1552
+ return parent ? ownerOf(parent) : detail;
1553
+ };
1554
+ const wholeVerseReading = (detail) => {
1555
+ if (detail.run.kind === "verse") return true;
1556
+ if (detail.run.kind !== "span") return false;
1557
+ const line = detail.run.line - 1;
1558
+ const poem = verseContexts.get(line);
1559
+ if (!poem) return false;
1560
+ const end = starts[line] + lines[line].length;
1561
+ if (detail.run.end > end) return false;
1562
+ const reading = verseReadings.get(line);
1563
+ const from =
1564
+ hasColumns(poem) ? starts[line] + (reading?.textColumn ?? 1) - 1 : starts[line];
1565
+ return (
1566
+ from <= detail.run.start &&
1567
+ /^[ \t.]*$/.test(text.slice(from, detail.run.start)) &&
1568
+ /^[ \t\r]*$/.test(text.slice(detail.run.end, end))
1569
+ );
1570
+ };
1571
+ const spanDetails = new Map(
1572
+ details
1573
+ .filter((detail) => detail.run.kind === "span")
1574
+ .map((detail) => [detail.run.start, detail]),
1575
+ );
1576
+ for (const owner of details.filter((detail) => ownerOf(detail) === detail)) {
1577
+ const bounds = boundsOf(owner);
1578
+ if (!bounds || !owner.checkRoman) continue;
1579
+ const owned = details.filter((detail) => ownerOf(detail) === owner);
1580
+ for (const detail of owned) detail.owner = owner;
1581
+ const excluded = [
1582
+ ...[...verseMarkers].filter(([start]) => start >= bounds[0] && start < bounds[1]),
1583
+ ...owned.flatMap((detail) =>
1584
+ detail.emphasis.map((delimiter) => [
1585
+ delimiter.offset,
1586
+ delimiter.offset + delimiter.length,
1587
+ ]),
1588
+ ),
1589
+ ];
1590
+ const projectionOptions = {
1591
+ from: bounds[0],
1592
+ to: bounds[1],
1593
+ spans,
1594
+ exclude: excluded,
1595
+ includeSpan: (span) =>
1596
+ spanDetails.has(span.start) && ownerOf(spanDetails.get(span.start)) === owner,
1597
+ };
1598
+ const verse = wholeVerseReading(owner);
1599
+ const projection =
1600
+ verse ?
1601
+ plainRomanVerse(text, {
1602
+ ...projectionOptions,
1603
+ isCantoLine: (line) =>
1604
+ STANZA_BREAK.test(line.trim()) || /^---\s+\{\.canto\}$/.test(line.trim()),
1605
+ })
1606
+ : plainRomanRun(text, projectionOptions);
1607
+ const before = errors.length;
1608
+ const inventory = inventoryOf(owner.run.lang);
1609
+ if (inventory && !inventory.characters)
1610
+ report(
1611
+ { line: owner.run.line, column: owner.run.column },
1612
+ `the language note for \`${inventory.language}\` must declare \`data.roman\``,
1613
+ );
1614
+ else if (inventory)
1615
+ for (const problem of romanTextProblems(projection.text, inventory))
1616
+ report(
1617
+ positionOf(starts, projection.offsets[problem.offset] ?? bounds[0]),
1618
+ problem.message,
1619
+ );
1620
+ for (const problem of projection.errors ?? [])
1621
+ report(positionOf(starts, problem.offset), problem.message);
1622
+ if (!verse && verseContexts.has(owner.run.line - 1))
1623
+ for (const match of projection.text.matchAll(/\//g)) {
1624
+ if (
1625
+ projection.text.lastIndexOf("⟦", match.index) >
1626
+ projection.text.lastIndexOf("⟧", match.index)
1627
+ )
1628
+ continue;
1629
+ report(
1630
+ positionOf(starts, projection.offsets[match.index]),
1631
+ "a verse fence uses authored line endings for verse boundaries; write no literal `/` inside it",
1632
+ );
1723
1633
  }
1634
+ const book = ctx.wordBooks?.get(owner.run.lang);
1635
+ if (book) {
1636
+ const mask = projection.text.split("");
1637
+ for (const match of projection.text.matchAll(/⟦[^]*?(?:⟧|$)/g))
1638
+ mask.fill(" ", match.index, match.index + match[0].length);
1639
+ for (const span of projection.spans.filter((span) => !span.included))
1640
+ mask.fill(" ", span.from, span.to);
1641
+ for (const problem of unknownWords(mask.join(""), book))
1642
+ report(
1643
+ positionOf(starts, projection.offsets[problem.offset] ?? bounds[0]),
1644
+ unknownWordMessage(problem.word, owner.run.lang),
1645
+ );
1646
+ }
1647
+ // Roman spelling and structure belong to the authored source even
1648
+ // when its selected encoding or face cannot be resolved. Only the
1649
+ // native conversion and glyph checks depend on that selection.
1650
+ if (!owner.checkText) continue;
1651
+ if (!owner.selection) {
1652
+ const marker = projection.text.indexOf("⟦");
1653
+ if (marker >= 0 && !encodingUnavailable)
1654
+ report(
1655
+ positionOf(starts, projection.offsets[marker]),
1656
+ "literal codage names no encoding; state `enc=`",
1657
+ );
1658
+ continue;
1724
1659
  }
1660
+ if (errors.length !== before) continue;
1661
+ const result = encodeWithLanguage(owner.selection.encoding, projection.text, {
1662
+ font: owner.selection.font,
1663
+ lexicon: owner.selection.lexicon,
1664
+ });
1665
+ const opaque = projection.spans.filter((span) => !span.included);
1666
+ const braces = new Map(
1667
+ projection.spans
1668
+ .filter((span) => span.included && span.name)
1669
+ .flatMap((span) => [
1670
+ [span.from, { anchor: span.source.start + 1 }],
1671
+ [span.to - 1, { anchor: span.source.contentEnd, closing: true }],
1672
+ ]),
1673
+ );
1674
+ const pieces = result.pieces
1675
+ .filter(
1676
+ (piece) => !opaque.some((span) => piece.start >= span.from && piece.end <= span.to),
1677
+ )
1678
+ .filter((piece) => !verse || !/[\r\n]/.test(piece.from) || piece.from.startsWith("⟦"))
1679
+ .map((piece) => {
1680
+ const brace = braces.get(piece.start);
1681
+ return {
1682
+ ...piece,
1683
+ offset: projection.offsets[piece.start] ?? bounds[0],
1684
+ anchor: brace?.anchor ?? projection.offsets[piece.start] ?? bounds[0],
1685
+ closing: brace?.closing ?? false,
1686
+ };
1687
+ });
1688
+ for (const finding of result.findings) {
1689
+ if (!opaque.some((span) => finding.offset >= span.from && finding.offset < span.to))
1690
+ report(
1691
+ positionOf(starts, projection.offsets[finding.offset] ?? bounds[0]),
1692
+ finding.message,
1693
+ );
1694
+ }
1695
+ if (verse) {
1696
+ for (const boundary of projection.boundaries) {
1697
+ const target =
1698
+ boundary.kind === "line" ?
1699
+ encodeText(owner.selection.encoding, "/", { font: owner.selection.font })
1700
+ : owner.selection.encoding.controls[
1701
+ boundary.kind === "stanza" ? "verseStanza" : "verseCanto"
1702
+ ];
1703
+ if (target === undefined) continue;
1704
+ pieces.push({
1705
+ from: "",
1706
+ to: target,
1707
+ start: boundary.at,
1708
+ end: boundary.at,
1709
+ offset: boundary.sourceOffset,
1710
+ anchor: boundary.sourceOffset,
1711
+ boundary: boundary.kind,
1712
+ closing: true,
1713
+ });
1714
+ }
1715
+ }
1716
+ pieces.sort((a, b) => a.anchor - b.anchor || a.start - b.start);
1717
+ const said = new Set();
1718
+ if (owner.face)
1719
+ for (const piece of pieces)
1720
+ for (const char of piece.to) {
1721
+ const point = char.codePointAt(0);
1722
+ if (
1723
+ /\s/u.test(char) ||
1724
+ /\p{Default_Ignorable_Code_Point}/u.test(char) ||
1725
+ owner.face.draws.has(point) ||
1726
+ said.has(point)
1727
+ )
1728
+ continue;
1729
+ said.add(point);
1730
+ report(
1731
+ positionOf(starts, piece.offset),
1732
+ `\`${piece.from}\` encodes to ${codeName(point)} in \`${owner.selection.encoding.name}\`, which the font \`${owner.face.slug}\` does not draw`,
1733
+ );
1734
+ }
1735
+ const plan = { owner, bounds, projection, pieces, verse, emitted: new Set() };
1736
+ for (const detail of owned) detail.plan = plan;
1725
1737
  }
1726
1738
 
1727
1739
  for (const reference of rawReferences) {
1728
- const font = reference.construct.keys.font ?? ctx.writtenIn?.get(reference.lang)?.font;
1740
+ const font = reference.font;
1729
1741
  roman([[reference.from, reference.to]], {
1730
1742
  run: {
1731
1743
  lang: reference.lang,
1732
- nativefont: native(reference.construct),
1744
+
1733
1745
  ...reference.where,
1734
1746
  },
1735
1747
  face: font === undefined ? undefined : faces.get(font),
1736
1748
  });
1737
1749
  }
1738
1750
 
1751
+ const firstEncodingRun =
1752
+ active.find(native) ??
1753
+ rawReferences.find((reference) => native(reference.construct))?.construct;
1754
+ if (encodingUnavailable && firstEncodingRun) {
1755
+ if (encodingUnavailable.file)
1756
+ errors.push({
1757
+ file: encodingUnavailable.file,
1758
+ line: encodingUnavailable.line ?? 1,
1759
+ column: encodingUnavailable.column ?? 1,
1760
+ message: encodingUnavailable.message,
1761
+ });
1762
+ else report(nativeAt(firstEncodingRun), encodingUnavailable.message);
1763
+ }
1764
+ for (const match of matchAllOutsideCode(text, /⟦/g)) {
1765
+ if (encodingUnavailable && firstEncodingRun) continue;
1766
+ if (
1767
+ details.some(
1768
+ (detail) =>
1769
+ detail.selection &&
1770
+ boundsOf(detail) &&
1771
+ boundsOf(detail)[0] <= match.index &&
1772
+ match.index < boundsOf(detail)[1],
1773
+ )
1774
+ )
1775
+ continue;
1776
+ const where = positionOf(starts, match.index);
1777
+ if (
1778
+ !errors.some(
1779
+ (error) =>
1780
+ error.line === where.line &&
1781
+ error.column === where.column &&
1782
+ error.message.includes("literal codage"),
1783
+ )
1784
+ )
1785
+ report(where, "literal codage names no encoding; state `enc=`");
1786
+ }
1787
+
1739
1788
  const order = (a, b) => a.line - b.line || a.column - b.column;
1740
1789
  runs.sort(order);
1741
1790
  details.sort((a, b) => order(a.run, b.run));
@@ -1744,21 +1793,44 @@ function analyseRuns(source, ctx, syntax) {
1744
1793
  return { runs, details, errors, warnings, spans, divs, poems };
1745
1794
  }
1746
1795
 
1796
+ /**
1797
+ * Diagnostic columns count Unicode code points. Parsing, edits and run records
1798
+ * keep their UTF-16 source coordinates; foreign-file loader positions already
1799
+ * use the encoding file's own code-point columns.
1800
+ * @param {string} source - Original authored text.
1801
+ * @param {Array<{file?: string, line: number, column: number, message: string}>} findings - Internal findings.
1802
+ * @returns {Array<{file?: string, line: number, column: number, message: string}>} Public diagnostic positions.
1803
+ */
1804
+ function diagnosticColumns(source, findings) {
1805
+ const lines = source.split("\n");
1806
+ return findings.map((finding) =>
1807
+ finding.file ? finding : (
1808
+ {
1809
+ ...finding,
1810
+ column:
1811
+ [...(lines[finding.line - 1] ?? "").slice(0, Math.max(0, finding.column - 1))]
1812
+ .length + 1,
1813
+ }
1814
+ ),
1815
+ );
1816
+ }
1817
+
1747
1818
  /**
1748
1819
  * Find the spans, divs and verse fences marked with `lang=`, `font=` or
1749
- * `map=`, and every fault in their markup and their text.
1820
+ * `enc=`, and every fault in their markup and their text.
1750
1821
  *
1751
1822
  * @param {string} source - Authored Markdown: a note body, or a whole file
1752
1823
  * whose frontmatter is blanked.
1753
1824
  * @param {RunContext} [ctx] - The configured languages, the installed faces
1754
- * and the maps. Without them every `lang=` must be a tag, every face and
1755
- * map is unknown, and only `mono`, `serif` and `sans` are fonts.
1825
+ * and the encodings. Without them every `lang=` must be a tag, every face and
1826
+ * enc is unknown, and only `mono`, `serif` and `sans` are fonts.
1756
1827
  * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session
1757
1828
  * scanner projections, so the source's spans and blocks are scanned once.
1758
- * @returns {{runs: Run[], errors: Array<{line: number, column: number, message: string}>, warnings: Array<{line: number, column: number, message: string}>}}
1829
+ * @returns {{runs: Run[], errors: Array<{file?: string, line: number, column: number, message: string}>, warnings: Array<{line: number, column: number, message: string}>}}
1759
1830
  * The runs in source order, and each finding at its 1-based position. A
1760
1831
  * warning is a `lang=` read as a tag that sits within one edit of a
1761
- * declared name.
1832
+ * declared name. Diagnostic columns count Unicode code points; each run's
1833
+ * source offsets and column remain UTF-16 units.
1762
1834
  */
1763
1835
  export function scanRuns(source, ctx = {}, syntax) {
1764
1836
  const text = String(source ?? "");
@@ -1773,9 +1845,9 @@ export function scanRuns(source, ctx = {}, syntax) {
1773
1845
  const merged = (found, extra) =>
1774
1846
  [...found, ...extra].sort((a, b) => a.line - b.line || a.column - b.column);
1775
1847
  return {
1776
- ...authored,
1777
- errors: merged(authored.errors, metre.errors),
1778
- warnings: merged(authored.warnings, metre.warnings),
1848
+ runs: authored.runs,
1849
+ errors: diagnosticColumns(text, merged(authored.errors, metre.errors)),
1850
+ warnings: diagnosticColumns(text, merged(authored.warnings, metre.warnings)),
1779
1851
  };
1780
1852
  }
1781
1853
 
@@ -1825,15 +1897,29 @@ const SCRIPT_BLOCK_CLASS = "script-block";
1825
1897
  * @returns {string|undefined} The text between the braces.
1826
1898
  */
1827
1899
  function rewrittenAttributes(detail, search) {
1828
- const { run, own, keys, face, generic, textMap } = detail;
1900
+ const { run, own, keys, face, generic, encoding } = detail;
1901
+ const emphasis = own.classes.find((name) => name === "emph" || name === "strong");
1902
+ if (emphasis && !Object.keys(keys).length) {
1903
+ const convention = detail.conventions?.[emphasis === "emph" ? "em" : "strong"];
1904
+ return attributeText({
1905
+ lang: undefined,
1906
+ ...own,
1907
+ classes: [...own.classes, ...(convention ? [EMPHASIS_CLASSES[convention]] : [])],
1908
+ });
1909
+ }
1829
1910
  const changes =
1830
1911
  own.classes.includes(NATIVEFONT_CLASS) ||
1831
1912
  run.font !== undefined ||
1832
- run.map !== undefined ||
1913
+ run.enc !== undefined ||
1833
1914
  (run.tag !== undefined &&
1834
1915
  (keys.lang !== run.tag || run.kind === "verse" || run.kind === "scroll"));
1835
1916
  if (!changes) return undefined;
1836
- const classes = own.classes.filter((name) => name !== NATIVEFONT_CLASS);
1917
+ const convention =
1918
+ emphasis ? detail.conventions?.[emphasis === "emph" ? "em" : "strong"] : undefined;
1919
+ const classes = [
1920
+ ...own.classes.filter((name) => name !== NATIVEFONT_CLASS),
1921
+ ...(convention ? [EMPHASIS_CLASSES[convention]] : []),
1922
+ ];
1837
1923
  const values = [...own.values];
1838
1924
  if (face) {
1839
1925
  classes.push(SCRIPT_CLASS);
@@ -1842,7 +1928,7 @@ function rewrittenAttributes(detail, search) {
1842
1928
  if (run.kind !== "span" && face.direction === "rtl") values.push(["dir", "rtl"]);
1843
1929
  if (search) values.push(["data-pagefind-ignore", "true"]);
1844
1930
  } else if (generic) classes.push(generic);
1845
- if (run.map !== undefined && !textMap) return undefined;
1931
+ if (run.enc !== undefined && run.enc !== "default" && !encoding) return undefined;
1846
1932
  return attributeText({ lang: run.tag, id: own.id, classes, values });
1847
1933
  }
1848
1934
 
@@ -1880,6 +1966,37 @@ function emphasisTag(delimiter, conventions, book) {
1880
1966
  * @returns {string} The set text.
1881
1967
  */
1882
1968
  function setRange(text, from, to, detail, lineStart, book) {
1969
+ if (detail.plan) {
1970
+ const events = detail.plan.pieces.filter(
1971
+ (piece) =>
1972
+ piece.boundary !== "canto" &&
1973
+ ((piece.anchor >= from && piece.anchor < to) ||
1974
+ (piece.anchor === to && piece.closing)),
1975
+ );
1976
+ for (const piece of events) detail.plan.emitted.add(piece);
1977
+ const breaks = [...text.slice(from, to).matchAll(/\r\n|\r|\n/g)].map((match) => ({
1978
+ anchor: from + match.index,
1979
+ text: match[0],
1980
+ newline: true,
1981
+ }));
1982
+ const emphasis = detail.emphasis
1983
+ .filter((delimiter) => delimiter.offset >= from && delimiter.offset < to)
1984
+ .map((delimiter) => ({
1985
+ anchor: delimiter.offset,
1986
+ text: emphasisTag(delimiter, detail.conventions, book),
1987
+ markup: true,
1988
+ }));
1989
+ const output = [...events, ...breaks, ...emphasis].sort(
1990
+ (a, b) => a.anchor - b.anchor || Number(a.newline) - Number(b.newline),
1991
+ );
1992
+ return output
1993
+ .map((event) =>
1994
+ event.newline || event.markup ?
1995
+ event.text
1996
+ : escapeRoman(event.to, lineStart && event.anchor === from),
1997
+ )
1998
+ .join("");
1999
+ }
1883
2000
  let out = "";
1884
2001
  let at = from;
1885
2002
  for (const delimiter of detail.emphasis) {
@@ -1887,24 +2004,25 @@ function setRange(text, from, to, detail, lineStart, book) {
1887
2004
  if (delimiter.offset > at)
1888
2005
  out += setText(
1889
2006
  text.slice(at, delimiter.offset),
1890
- detail.mapping,
2007
+ detail.selection,
1891
2008
  at === from && lineStart,
1892
2009
  ).text;
1893
2010
  out += emphasisTag(delimiter, detail.conventions, book);
1894
2011
  at = delimiter.offset + delimiter.length;
1895
2012
  }
1896
- if (at < to) out += setText(text.slice(at, to), detail.mapping, at === from && lineStart).text;
2013
+ if (at < to)
2014
+ out += setText(text.slice(at, to), detail.selection, at === from && lineStart).text;
1897
2015
  return out;
1898
2016
  }
1899
2017
 
1900
2018
  /**
1901
- * Rewrite text marked with `lang=`, `font=` and `map=` into the plain span,
2019
+ * Rewrite text marked with `lang=`, `font=` and `enc=` into the plain span,
1902
2020
  * div and verse grammar the rest of each chain renders, one line for one line.
1903
2021
  *
1904
2022
  * - **Numbered verse prefixes and indentation markers** stay outside run text.
1905
2023
  * Each authored run chooses its own language and presentation; physical
1906
2024
  * lines and source positions remain unchanged.
1907
- * - **The map, if any, is applied first.** Characters Markdown would read are
2025
+ * - **The enc, if any, is applied first.** Characters Markdown would read are
1908
2026
  * then backslash-escaped, so they reach every later pass as characters.
1909
2027
  * - **Emphasis in a face** becomes an `<em>` or a `<strong>` carrying its
1910
2028
  * convention's class, or no class where none is stated. The book reads
@@ -1918,19 +2036,19 @@ function setRange(text, from, to, detail, lineStart, book) {
1918
2036
  * mark, `<bdo>` or `<bdi>` is ever written: the code points carry their own
1919
2037
  * direction.
1920
2038
  * - **A scroll fence's body** is rewritten to one line per paragraph, its
1921
- * lines joined by what the map writes for a space, and left unescaped,
2039
+ * lines joined by what the enc writes for a space, and left unescaped,
1922
2040
  * since the body is raw. Its layout, each character with the source text it
1923
2041
  * was written from, is returned for the book, which draws it.
1924
2042
  *
1925
2043
  * A run with a finding is left as written; {@link scanRuns} reports it.
1926
2044
  *
1927
2045
  * @param {string} source - Authored Markdown: a note body.
1928
- * @param {RunContext} [ctx] - The configured languages, faces and maps.
2046
+ * @param {RunContext} [ctx] - The configured languages, faces and encodings.
1929
2047
  * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session
1930
2048
  * scanner projections.
1931
2049
  * @param {{target?: "site"|"foundry"|"book"}} [options] - The target the
1932
2050
  * Markdown is rendered for; only `site` writes the search marker.
1933
- * @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>, scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>}}
2051
+ * @returns {{markdown: string, errors: Array<{file?: string, line: number, column: number, message: string}>, scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>}}
1934
2052
  * The rewritten Markdown, the findings of {@link scanRuns}, and each
1935
2053
  * rewritten scroll fence's layout, by its rewritten body.
1936
2054
  */
@@ -1940,19 +2058,22 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1940
2058
  const text = String(source ?? "");
1941
2059
  /** @type {Map<string, import("./content-scroll.mjs").ScrollLayout>} */
1942
2060
  const scrolls = new Map();
1943
- if (!/\b(?:lang|font|map)=|\bnativefont\b/.test(text))
2061
+ if (!/\b(?:lang|font|enc)=|\bnativefont\b|\.(?:emph|strong)\b|⟦/.test(text))
1944
2062
  return { markdown: text, errors: [], scrolls };
1945
2063
  const { details, errors } = analyseRuns(text, ctx, syntax);
1946
- if (!details.length) return { markdown: text, errors, scrolls };
2064
+ if (!details.length)
2065
+ return { markdown: text, errors: diagnosticColumns(text, errors), scrolls };
1947
2066
  const lines = text.split("\n");
1948
2067
  const starts = lineStarts(text);
1949
2068
 
1950
2069
  /** Whether a finding sits inside a run. */
1951
2070
  const faulted = (run) => {
2071
+ if (run.enc !== "default" && errors.some((error) => error.file)) return true;
1952
2072
  const from = (run.line - 1) * 1e6 + run.column;
1953
2073
  const end = run.kind === "span" ? positionOf(starts, run.end) : undefined;
1954
2074
  const to = end ? (end.line - 1) * 1e6 + end.column : (run.end + 1) * 1e6;
1955
2075
  return errors.some((error) => {
2076
+ if (error.file) return false;
1956
2077
  const at = (error.line - 1) * 1e6 + error.column;
1957
2078
  return at >= from && at < to;
1958
2079
  });
@@ -1969,7 +2090,7 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1969
2090
  * The body and the layout.
1970
2091
  */
1971
2092
  const setScroll = (detail) => {
1972
- const join = setScrollText(" ", detail.mapping);
2093
+ const join = setScrollText(" ", detail.selection);
1973
2094
  const texts = [];
1974
2095
  const paragraphs = [];
1975
2096
  for (const [top, past] of paragraphRanges(lines, detail.run.start + 1, detail.run.end)) {
@@ -1980,7 +2101,9 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1980
2101
  let pending = "";
1981
2102
  const emit = (pieces) => {
1982
2103
  for (const piece of pieces) {
1983
- const chars = [...piece.to];
2104
+ const chars = [...piece.to].filter(
2105
+ (char) => !/\p{Default_Ignorable_Code_Point}/u.test(char),
2106
+ );
1984
2107
  if (!chars.length) {
1985
2108
  pending += piece.from;
1986
2109
  continue;
@@ -1997,7 +2120,13 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1997
2120
  }
1998
2121
  };
1999
2122
  const set = (from, to) => {
2000
- const result = setScrollText(text.slice(from, to), detail.mapping);
2123
+ const pieces = detail.plan?.pieces.filter(
2124
+ (piece) => piece.anchor >= from && piece.anchor < to,
2125
+ );
2126
+ const result =
2127
+ pieces ?
2128
+ { text: pieces.map((piece) => piece.to).join(""), pieces }
2129
+ : setScrollText(text.slice(from, to), detail.selection);
2001
2130
  out += result.text;
2002
2131
  emit(result.pieces);
2003
2132
  };
@@ -2029,7 +2158,7 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2029
2158
 
2030
2159
  for (const detail of details) {
2031
2160
  const { run } = detail;
2032
- if (faulted(run)) continue;
2161
+ if (faulted(run) || (detail.owner && faulted(detail.owner.run))) continue;
2033
2162
  const attributes = rewrittenAttributes(detail, search);
2034
2163
  if (run.kind === "span") {
2035
2164
  const contentEnd = /** @type {number} */ (detail.contentEnd);
@@ -2057,7 +2186,13 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2057
2186
  });
2058
2187
  continue;
2059
2188
  }
2060
- if (attributes !== undefined)
2189
+ const emphasis = detail.own.classes.some(
2190
+ (name) => name === "emph" || name === "strong",
2191
+ );
2192
+ if (emphasis && book && detail.face && !detail.conventions) {
2193
+ edits.push({ from: run.start, to: run.start + 1, text: "" });
2194
+ edits.push({ from: contentEnd, to: run.end, text: "" });
2195
+ } else if (attributes !== undefined)
2061
2196
  edits.push({ from: contentEnd + 2, to: run.end - 1, text: attributes });
2062
2197
  if (detail.plain)
2063
2198
  for (const [from, to] of detail.reference ?
@@ -2101,8 +2236,28 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2101
2236
  }
2102
2237
  }
2103
2238
 
2239
+ for (const detail of details) {
2240
+ if (!detail.plan || detail.plan.owner !== detail || faulted(detail.run)) continue;
2241
+ for (const piece of detail.plan.pieces.filter(
2242
+ (piece) => piece.boundary && !detail.plan.emitted.has(piece),
2243
+ )) {
2244
+ if (piece.boundary === "canto") {
2245
+ const end = text.indexOf("\n", piece.anchor);
2246
+ edits.push({ from: piece.anchor, to: end < 0 ? text.length : end, text: piece.to });
2247
+ } else {
2248
+ // A final independent child may occupy the rest of the line.
2249
+ // Its boundary belongs after that child, in the enclosing face.
2250
+ edits.push({
2251
+ from: piece.anchor,
2252
+ to: piece.anchor,
2253
+ text: escapeRoman(piece.to, false),
2254
+ });
2255
+ }
2256
+ }
2257
+ }
2258
+
2104
2259
  let markdown = text;
2105
2260
  for (const edit of edits.sort((a, b) => b.from - a.from))
2106
2261
  markdown = markdown.slice(0, edit.from) + edit.text + markdown.slice(edit.to);
2107
- return { markdown, errors, scrolls };
2262
+ return { markdown, errors: diagnosticColumns(text, errors), scrolls };
2108
2263
  }