@heroiclands/package-build 23.12.6 → 23.12.8
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.
- package/CHANGELOG.md +18 -0
- package/assets/styles/content-base.css +25 -0
- package/bin/content-commands.mjs +264 -3
- package/bin/package-build.mjs +25 -0
- package/content-config.mjs +72 -0
- package/docs/api.md +10 -9
- package/docs/authoring/assets.md +1 -1
- package/docs/authoring/links-and-markup.md +439 -47
- package/docs/commands.md +137 -3
- package/docs/configuration.md +73 -3
- package/docs/content-format.md +3 -1
- package/docs/guides/book.md +11 -0
- package/docs/guides/module-project.md +16 -0
- package/docs/guides/site.md +19 -0
- package/docs/reference/format-details.md +378 -6
- package/docs/reference/note-types.md +36 -11
- package/e2e.mjs +1 -1
- package/engine/asset-shipping.mjs +22 -8
- package/engine/base-compiler.mjs +2 -0
- package/engine/bundles.mjs +7 -2
- package/engine/compile-corpus.mjs +3 -1
- package/engine/content-charset.mjs +21 -2
- package/engine/content-format.yaml +1 -0
- package/engine/content-index.mjs +12 -0
- package/engine/content-poetry.mjs +22 -4
- package/engine/content-scripts.mjs +1219 -0
- package/engine/content-session.mjs +2 -0
- package/engine/data-keys.mjs +5 -1
- package/engine/dictionary-layout.mjs +18 -12
- package/engine/dictionary-rows.mjs +11 -2
- package/engine/helpers.mjs +8 -2
- package/engine/index-records.mjs +1 -0
- package/engine/journals.mjs +14 -3
- package/engine/language-data.mjs +137 -1
- package/engine/language-tag.mjs +103 -0
- package/engine/language-terms.mjs +16 -4
- package/engine/map-lexicon.mjs +762 -0
- package/engine/markup-validation.mjs +17 -3
- package/engine/names.mjs +1 -1
- package/engine/note-vocabulary.mjs +113 -0
- package/engine/pdf-build.mjs +28 -10
- package/engine/pdf-render.mjs +116 -11
- package/engine/run-rows.mjs +119 -0
- package/engine/run-words.mjs +266 -0
- package/engine/scenes.mjs +1 -0
- package/engine/script-font-delivery.mjs +321 -0
- package/engine/script-fonts.mjs +172 -0
- package/engine/site-build.mjs +10 -3
- package/engine/site-config.mjs +5 -0
- package/engine/site-root.mjs +19 -5
- package/engine/sql-tables.mjs +111 -1
- package/engine/text-maps.mjs +607 -0
- package/engine/verb-pattern.mjs +99 -0
- package/engine/word-pattern.mjs +27 -0
- package/engine/word-rows.mjs +8 -2
- package/engine/word-terms.mjs +44 -0
- package/hugo-theme/layouts/_default/baseof.html +3 -0
- package/hugo-theme/static/css/style.css +10 -0
- package/manifest.mjs +9 -1
- package/package.json +2 -2
- package/types/content-config.d.mts +26 -0
- package/types/e2e.d.mts +2 -2
- package/types/engine/address-fields.d.mts +1 -1
- package/types/engine/address-values.d.mts +1 -1
- package/types/engine/asset-shipping.d.mts +5 -0
- package/types/engine/compile-corpus.d.mts +2 -1
- package/types/engine/content-format-check.d.mts +1 -1
- package/types/engine/content-index.d.mts +11 -3
- package/types/engine/content-poetry.d.mts +7 -0
- package/types/engine/content-scripts.d.mts +226 -0
- package/types/engine/data-keys.d.mts +2 -2
- package/types/engine/dictionary-rows.d.mts +15 -0
- package/types/engine/helpers.d.mts +5 -1
- package/types/engine/journals.d.mts +7 -2
- package/types/engine/language-data.d.mts +2 -0
- package/types/engine/language-tag.d.mts +8 -0
- package/types/engine/language-terms.d.mts +2 -1
- package/types/engine/map-lexicon.d.mts +274 -0
- package/types/engine/markup-validation.d.mts +7 -2
- package/types/engine/note-vocabulary.d.mts +1 -1
- package/types/engine/pdf-build.d.mts +26 -3
- package/types/engine/pdf-coverage.d.mts +1 -1
- package/types/engine/pdf-render.d.mts +17 -2
- package/types/engine/run-rows.d.mts +89 -0
- package/types/engine/run-words.d.mts +86 -0
- package/types/engine/script-font-delivery.d.mts +134 -0
- package/types/engine/script-fonts.d.mts +168 -0
- package/types/engine/site-build.d.mts +2 -1
- package/types/engine/site-root.d.mts +11 -3
- package/types/engine/text-maps.d.mts +175 -0
- package/types/engine/verb-pattern.d.mts +65 -0
- package/types/engine/word-pattern.d.mts +13 -0
- package/types/engine/word-terms.d.mts +6 -0
- package/types/release.d.mts +1 -1
|
@@ -0,0 +1,1219 @@
|
|
|
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
|
+
* **Text marked with a language, a font and a mapping.** Three keys on the
|
|
16
|
+
* attribute grammar spans, divs and poetry fences already share, each optional
|
|
17
|
+
* and independent of the others:
|
|
18
|
+
*
|
|
19
|
+
* ```markdown
|
|
20
|
+
* [Daimela, helthos.]{lang=vyl}
|
|
21
|
+
* [Zaron tholmeza]{lang=vyl font=semaron-kuriale map=vyl-semaron}
|
|
22
|
+
*
|
|
23
|
+
* ::: {lang=vyl font=semaron-kuriale map=vyl-semaron}
|
|
24
|
+
* Zaron tholmeza.
|
|
25
|
+
* :::
|
|
26
|
+
*
|
|
27
|
+
* ```poetry {form=epic lang=vyl font=semaron-kuriale map=vyl-semaron}
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* - **`lang=`** gives the language tag and changes nothing else. It is a name
|
|
31
|
+
* declared in the package's `languages`, which gives its tag, or otherwise a
|
|
32
|
+
* well-formed BCP 47 tag, checked by form alone.
|
|
33
|
+
* - **`font=`** is one of the installed faces, by its class without `hl-`
|
|
34
|
+
* (`semaron-kuriale`), or `mono`, `serif` or `sans`.
|
|
35
|
+
* - **`map=`** names a mapping note's `data.map`, which rewrites the text
|
|
36
|
+
* before it is set; see {@link module:engine/text-maps}. Without it the text
|
|
37
|
+
* is set as typed. A run's `font=` is one of the faces the map lists in its
|
|
38
|
+
* `fonts`, whose own rows come before the shared ones.
|
|
39
|
+
* - **`lang=` with `map=` reads the language's lexicon first**: a word whose
|
|
40
|
+
* entry states a native form for the map is written by it, and the rows
|
|
41
|
+
* spell every other word; see {@link module:engine/map-lexicon}.
|
|
42
|
+
* - **Every word of a run with `lang=` and `map=` is a word of its language**
|
|
43
|
+
* where the language's `note` names its language note: a lexicon form, a
|
|
44
|
+
* word of a name its own register lists, or a regular form of a lexicon
|
|
45
|
+
* form. Any other
|
|
46
|
+
* word is a finding at its first character, once per run; see
|
|
47
|
+
* {@link module:engine/run-words}.
|
|
48
|
+
* - **A run inside a div or a poetry fence inherits its keys**, and its own
|
|
49
|
+
* keys override them.
|
|
50
|
+
* - **Text with a font, a map or a declared language is plain.** A span or a
|
|
51
|
+
* verse holds no emphasis, link, image, footnote, wikilink, code or HTML,
|
|
52
|
+
* and no span but a marked one; a div holds paragraphs and line breaks only.
|
|
53
|
+
* The text is read as written, so `[`, `]`, `"`, `<<`, `>>`, `!` and
|
|
54
|
+
* `@#$%^&` are characters, and need no escaping. A real language's tag alone
|
|
55
|
+
* marks text and leaves its Markdown alone.
|
|
56
|
+
* - **A face draws only what it lists.** After the map, a character that is
|
|
57
|
+
* neither whitespace nor in the face's `codepoints` is a finding, at the
|
|
58
|
+
* source character it came from. `mono`, `serif` and `sans` are not checked.
|
|
59
|
+
*
|
|
60
|
+
* {@link scanRuns} reads the markup through {@link module:engine/content-spans.scanSpans},
|
|
61
|
+
* {@link module:engine/content-blocks.scanBlocks} and
|
|
62
|
+
* {@link module:engine/content-poetry.scanPoetry}, so what counts as a span, a
|
|
63
|
+
* div or a fence is theirs to say.
|
|
64
|
+
*
|
|
65
|
+
* @module
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
import MarkdownIt from "markdown-it";
|
|
69
|
+
|
|
70
|
+
import { scanBlocks, BLOCK_CONTAINERS } from "./content-blocks.mjs";
|
|
71
|
+
import { scanPoetry } from "./content-poetry.mjs";
|
|
72
|
+
import { scanSpans } from "./content-spans.mjs";
|
|
73
|
+
import { isLanguageTag } from "./language-tag.mjs";
|
|
74
|
+
import { distance, nearest } from "./near-miss.mjs";
|
|
75
|
+
import { FONTS_PACKAGE, resolveScriptFonts } from "./script-fonts.mjs";
|
|
76
|
+
import {
|
|
77
|
+
fontProblem,
|
|
78
|
+
lexiconsFromRecords,
|
|
79
|
+
mappingFor,
|
|
80
|
+
uncapitalisedMessage,
|
|
81
|
+
} from "./map-lexicon.mjs";
|
|
82
|
+
import { unknownWordMessage, unknownWords, wordBooksFromRecords } from "./run-words.mjs";
|
|
83
|
+
import { applyMap, mapsFromRecords } from "./text-maps.mjs";
|
|
84
|
+
|
|
85
|
+
/** The keys this module reads, which no construct passes through as written. */
|
|
86
|
+
export const RUN_KEYS = Object.freeze(["lang", "font", "map"]);
|
|
87
|
+
|
|
88
|
+
/** The generic families `font=` may name, each with the class that sets it. */
|
|
89
|
+
export const GENERIC_FONTS = Object.freeze({
|
|
90
|
+
mono: "font-mono",
|
|
91
|
+
serif: "font-serif",
|
|
92
|
+
sans: "font-sans",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** The configuration file every unknown language points at. */
|
|
96
|
+
const CONFIG = "package-build.config.yaml";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What a run is checked against.
|
|
100
|
+
*
|
|
101
|
+
* @typedef {object} RunContext
|
|
102
|
+
* @property {Readonly<Record<string, {tag: string}>>} [languages] - The
|
|
103
|
+
* configured languages.
|
|
104
|
+
* @property {ReadonlyMap<string, import("./script-fonts.mjs").Face>} [faces] -
|
|
105
|
+
* The installed faces by slug, from
|
|
106
|
+
* {@link module:engine/script-fonts.resolveScriptFonts}.
|
|
107
|
+
* @property {ReadonlyMap<string, import("./text-maps.mjs").TextMap>} [maps] -
|
|
108
|
+
* The mapping notes' maps by name, from
|
|
109
|
+
* {@link module:engine/text-maps.mapsFromRecords}.
|
|
110
|
+
* @property {ReadonlyMap<string, import("./map-lexicon.mjs").Lexicon>} [lexicons] -
|
|
111
|
+
* Each configured language's lexicon by name, from
|
|
112
|
+
* {@link module:engine/map-lexicon.lexiconsFromRecords}.
|
|
113
|
+
* @property {ReadonlyMap<string, import("./run-words.mjs").WordBook>} [wordBooks] -
|
|
114
|
+
* What each configured language's mapped runs are checked against, from
|
|
115
|
+
* {@link module:engine/run-words.wordBooksFromRecords}.
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One span, div or poetry fence carrying `lang=`, `font=` or `map=`. Its
|
|
120
|
+
* `lang`, `font` and `map` are the ones it is set with: its own, else the
|
|
121
|
+
* innermost div or fence around it that states one.
|
|
122
|
+
*
|
|
123
|
+
* @typedef {object} Run
|
|
124
|
+
* @property {"span"|"block"|"poetry"} kind - Which construct.
|
|
125
|
+
* @property {string|undefined} lang - The language as written.
|
|
126
|
+
* @property {string|undefined} tag - The BCP 47 tag it resolves to; `undefined`
|
|
127
|
+
* when it resolves to none.
|
|
128
|
+
* @property {string|undefined} font - The font as written.
|
|
129
|
+
* @property {string|undefined} map - The map as written.
|
|
130
|
+
* @property {number} line - 1-based line of the construct's opening.
|
|
131
|
+
* @property {number} column - 1-based UTF-16 column of its opening.
|
|
132
|
+
* @property {number} start - A span's offset of `[`; a div's or fence's
|
|
133
|
+
* 0-based opening line.
|
|
134
|
+
* @property {number} end - A span's offset past its attributes; a div's or
|
|
135
|
+
* fence's 0-based closing line.
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
/** @type {WeakMap<object, RunContext>} */
|
|
139
|
+
const contexts = new WeakMap();
|
|
140
|
+
|
|
141
|
+
/** @type {WeakMap<object, WeakMap<object, RunContext>>} */
|
|
142
|
+
const withMaps = new WeakMap();
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The context a resolved configuration gives, and the maps an index holds.
|
|
146
|
+
*
|
|
147
|
+
* @param {{languages?: object, rootDir?: string}|undefined} config - The
|
|
148
|
+
* resolved configuration.
|
|
149
|
+
* @param {Iterable<Record<string, any>>} [records] - The content index's
|
|
150
|
+
* records, whose mapping notes give the maps. Without them no map is known.
|
|
151
|
+
* @returns {RunContext} Its languages, the faces installed where it sits, and
|
|
152
|
+
* the maps. A fonts package that cannot be read gives no faces; a run naming
|
|
153
|
+
* one is reported.
|
|
154
|
+
*/
|
|
155
|
+
export function runContext(config, records) {
|
|
156
|
+
if (!config) return {};
|
|
157
|
+
let base = contexts.get(config);
|
|
158
|
+
if (!base) {
|
|
159
|
+
let faces;
|
|
160
|
+
if (config.rootDir) {
|
|
161
|
+
try {
|
|
162
|
+
faces = resolveScriptFonts(config.rootDir)?.faces;
|
|
163
|
+
} catch {
|
|
164
|
+
faces = undefined;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
base = Object.freeze({
|
|
168
|
+
languages: config.languages ?? {},
|
|
169
|
+
faces: faces ?? new Map(),
|
|
170
|
+
contentPackage: config.contentPackage,
|
|
171
|
+
});
|
|
172
|
+
contexts.set(config, base);
|
|
173
|
+
}
|
|
174
|
+
if (!records || typeof records !== "object") return base;
|
|
175
|
+
let byRecords = withMaps.get(config);
|
|
176
|
+
if (!byRecords) withMaps.set(config, (byRecords = new WeakMap()));
|
|
177
|
+
const known = byRecords.get(records);
|
|
178
|
+
if (known) return known;
|
|
179
|
+
const context = Object.freeze({
|
|
180
|
+
...base,
|
|
181
|
+
maps: mapsFromRecords(records),
|
|
182
|
+
lexicons: lexiconsFromRecords(records, config),
|
|
183
|
+
wordBooks: wordBooksFromRecords(records, config),
|
|
184
|
+
});
|
|
185
|
+
byRecords.set(records, context);
|
|
186
|
+
return context;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Whether two names are one edit apart: a letter added, dropped or changed,
|
|
191
|
+
* or two neighbouring letters swapped. Case is ignored.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} a - One name.
|
|
194
|
+
* @param {string} b - The other.
|
|
195
|
+
* @returns {boolean} Whether they are within one edit.
|
|
196
|
+
*/
|
|
197
|
+
function withinOneEdit(a, b) {
|
|
198
|
+
a = a.toLowerCase();
|
|
199
|
+
b = b.toLowerCase();
|
|
200
|
+
if (distance(a, b) <= 1) return true;
|
|
201
|
+
if (a.length !== b.length) return false;
|
|
202
|
+
let at = 0;
|
|
203
|
+
while (a[at] === b[at]) at++;
|
|
204
|
+
return a[at] === b[at + 1] && a[at + 1] === b[at] && a.slice(at + 2) === b.slice(at + 2);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The word each construct is called in a finding. */
|
|
208
|
+
const CONSTRUCT = Object.freeze({ span: "span", block: "div", poetry: "poetry fence" });
|
|
209
|
+
|
|
210
|
+
/** Line starts of a source, for offsets to positions. */
|
|
211
|
+
function lineStarts(text) {
|
|
212
|
+
const starts = [0];
|
|
213
|
+
for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1);
|
|
214
|
+
return starts;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The 1-based position of an offset. */
|
|
218
|
+
function positionOf(starts, offset) {
|
|
219
|
+
let lo = 0,
|
|
220
|
+
hi = starts.length;
|
|
221
|
+
while (lo + 1 < hi) {
|
|
222
|
+
const mid = (lo + hi) >>> 1;
|
|
223
|
+
if (starts[mid] <= offset) lo = mid;
|
|
224
|
+
else hi = mid;
|
|
225
|
+
}
|
|
226
|
+
return { line: lo + 1, column: offset - starts[lo] + 1 };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Where a key and its value sit on an opening line, found by searching the
|
|
231
|
+
* line from its attribute block.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} line - The opening line.
|
|
234
|
+
* @param {string} key - The key.
|
|
235
|
+
* @returns {{key: number, value: number}} 1-based columns.
|
|
236
|
+
*/
|
|
237
|
+
function attributeColumns(line, key) {
|
|
238
|
+
const from = Math.max(0, line.indexOf("{"));
|
|
239
|
+
const match = new RegExp(`[\\s{]${key}=`).exec(line.slice(from));
|
|
240
|
+
if (!match) return { key: from + 1, value: from + 1 };
|
|
241
|
+
const at = from + match.index + 1;
|
|
242
|
+
const value = at + key.length + 1;
|
|
243
|
+
return { key: at + 1, value: value + (line[value] === '"' ? 1 : 0) + 1 };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** ASCII and Unicode punctuation, for the delimiter rules of emphasis. */
|
|
247
|
+
const PUNCTUATION = /[\p{P}\p{S}]/u;
|
|
248
|
+
const SPACE = /\s/u;
|
|
249
|
+
|
|
250
|
+
/** A parser that says whether text holds emphasis at all. */
|
|
251
|
+
const emphasisParser = new MarkdownIt({ html: true });
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Whether Markdown reads emphasis, strong emphasis or a strikethrough in text.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} text - Inline text.
|
|
257
|
+
* @returns {boolean} Whether it does.
|
|
258
|
+
*/
|
|
259
|
+
function holdsEmphasis(text) {
|
|
260
|
+
const tokens = emphasisParser.parseInline(text, {})[0]?.children ?? [];
|
|
261
|
+
return tokens.some((token) => ["em_open", "strong_open", "s_open"].includes(token.type));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** An inline HTML tag, comment or autolink at the start of a string. */
|
|
265
|
+
const HTML_START =
|
|
266
|
+
/^(?:<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*)?\/?>|<!--[\s\S]*?-->|<[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>]*>|<[^\s<>@]+@[^\s<>]+>)/;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The index of the bracket closing the one at `open`, or -1.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} text - The text.
|
|
272
|
+
* @param {number} open - The offset of `[` or `(`.
|
|
273
|
+
* @returns {number} The offset of its match.
|
|
274
|
+
*/
|
|
275
|
+
function closing(text, open) {
|
|
276
|
+
const [left, right] = text[open] === "(" ? ["(", ")"] : ["[", "]"];
|
|
277
|
+
let depth = 0;
|
|
278
|
+
for (let i = open; i < text.length; i++) {
|
|
279
|
+
if (text[i] === "\\") {
|
|
280
|
+
i++;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (text[i] === left) depth++;
|
|
284
|
+
else if (text[i] === right && --depth === 0) return i;
|
|
285
|
+
}
|
|
286
|
+
return -1;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The markup in text that must be plain, each at its offset.
|
|
291
|
+
*
|
|
292
|
+
* Read from the raw text: a bracket pair is a character pair unless a `(`, a
|
|
293
|
+
* `{` or a second bracket makes it a link, a span or a wikilink, and `[^id]`
|
|
294
|
+
* is a footnote only where the note defines `id`.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} text - The text, as written.
|
|
297
|
+
* @param {ReadonlySet<string>} footnotes - The labels the note defines.
|
|
298
|
+
* @param {ReadonlyMap<number, number>} [holes] - Marked spans inside the
|
|
299
|
+
* text, each its offset of `[` to the offset past its attributes. They are
|
|
300
|
+
* runs of their own and checked as such.
|
|
301
|
+
* @returns {Array<{offset: number, what: string}>} Each fault, in order.
|
|
302
|
+
*/
|
|
303
|
+
function inlineMarkup(text, footnotes, holes = new Map()) {
|
|
304
|
+
const faults = [];
|
|
305
|
+
const skip = new Map();
|
|
306
|
+
const runs = [];
|
|
307
|
+
for (let i = 0; i < text.length; i++) {
|
|
308
|
+
if (holes.has(i)) {
|
|
309
|
+
i = /** @type {number} */ (holes.get(i)) - 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (skip.has(i)) {
|
|
313
|
+
i = skip.get(i);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const char = text[i];
|
|
317
|
+
if (char === "\\") {
|
|
318
|
+
i++;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (char === "`") {
|
|
322
|
+
const fence = /^`+/.exec(text.slice(i))[0];
|
|
323
|
+
const close = new RegExp(`(?<!\`)${fence}(?!\`)`, "g");
|
|
324
|
+
close.lastIndex = i + fence.length;
|
|
325
|
+
const found = close.exec(text);
|
|
326
|
+
if (found) {
|
|
327
|
+
faults.push({ offset: i, what: "code" });
|
|
328
|
+
i = found.index + fence.length - 1;
|
|
329
|
+
} else i += fence.length - 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (char === "<") {
|
|
333
|
+
// `<<` and `>>` are a quotation's marks in a script that types them.
|
|
334
|
+
if (text[i + 1] === "<") {
|
|
335
|
+
while (text[i + 1] === "<") i++;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const tag = HTML_START.exec(text.slice(i));
|
|
339
|
+
if (tag) {
|
|
340
|
+
faults.push({ offset: i, what: "HTML" });
|
|
341
|
+
i += tag[0].length - 1;
|
|
342
|
+
}
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (char === "[") {
|
|
346
|
+
if (text[i + 1] === "[") {
|
|
347
|
+
const close = text.indexOf("]]", i + 2);
|
|
348
|
+
if (close > i + 2 && !text.slice(i + 2, close).includes("\n")) {
|
|
349
|
+
faults.push({ offset: i, what: "wikilink" });
|
|
350
|
+
i = close + 1;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const end = closing(text, i);
|
|
355
|
+
if (end < 0) continue;
|
|
356
|
+
const label = text.slice(i + 1, end);
|
|
357
|
+
const next = text[end + 1];
|
|
358
|
+
if (next === "(") {
|
|
359
|
+
const close = closing(text, end + 1);
|
|
360
|
+
if (close > 0) {
|
|
361
|
+
faults.push(
|
|
362
|
+
text[i - 1] === "!" ?
|
|
363
|
+
{ offset: i - 1, what: "image" }
|
|
364
|
+
: { offset: i, what: "link" },
|
|
365
|
+
);
|
|
366
|
+
skip.set(end + 1, close);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (next === "{") {
|
|
371
|
+
const close = text.indexOf("}", end + 1);
|
|
372
|
+
if (close > 0) {
|
|
373
|
+
faults.push({ offset: i, what: "nested span" });
|
|
374
|
+
skip.set(end + 1, close);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (/^\^[^\s\]]+$/.test(label) && footnotes.has(label.slice(1))) {
|
|
379
|
+
faults.push({ offset: i, what: "footnote" });
|
|
380
|
+
i = end;
|
|
381
|
+
}
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (char === "*" || char === "_" || (char === "~" && text[i + 1] === "~")) {
|
|
385
|
+
let j = i;
|
|
386
|
+
while (text[j] === char) j++;
|
|
387
|
+
const before = text[i - 1] ?? " ",
|
|
388
|
+
after = text[j] ?? " ";
|
|
389
|
+
const left =
|
|
390
|
+
!SPACE.test(after) &&
|
|
391
|
+
(!PUNCTUATION.test(after) || SPACE.test(before) || PUNCTUATION.test(before));
|
|
392
|
+
const right =
|
|
393
|
+
!SPACE.test(before) &&
|
|
394
|
+
(!PUNCTUATION.test(before) || SPACE.test(after) || PUNCTUATION.test(after));
|
|
395
|
+
runs.push({
|
|
396
|
+
offset: i,
|
|
397
|
+
char,
|
|
398
|
+
opens: char === "_" ? left && (!right || PUNCTUATION.test(before)) : left,
|
|
399
|
+
closes: char === "_" ? right && (!left || PUNCTUATION.test(after)) : right,
|
|
400
|
+
});
|
|
401
|
+
i = j - 1;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
let outside = text;
|
|
405
|
+
for (const [from, to] of holes)
|
|
406
|
+
outside = outside.slice(0, from) + " ".repeat(to - from) + outside.slice(to);
|
|
407
|
+
if (runs.length && holdsEmphasis(outside)) {
|
|
408
|
+
const used = new Set();
|
|
409
|
+
let found = false;
|
|
410
|
+
for (const [n, run] of runs.entries()) {
|
|
411
|
+
if (used.has(n) || !run.opens) continue;
|
|
412
|
+
const closer = runs.findIndex(
|
|
413
|
+
(other, m) => m > n && !used.has(m) && other.char === run.char && other.closes,
|
|
414
|
+
);
|
|
415
|
+
if (closer < 0) continue;
|
|
416
|
+
used.add(n).add(closer);
|
|
417
|
+
faults.push({ offset: run.offset, what: "emphasis" });
|
|
418
|
+
found = true;
|
|
419
|
+
}
|
|
420
|
+
if (!found) faults.push({ offset: 0, what: "emphasis" });
|
|
421
|
+
}
|
|
422
|
+
return faults.sort((a, b) => a.offset - b.offset);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** A line split into its indentation, its text, a trailing break and its trailing space. */
|
|
426
|
+
const LINE_PARTS = /^(\s*)(.*?)(\\?)(\s*)$/;
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Backslash-escape the characters Markdown reads as markup, so the text of a
|
|
430
|
+
* run reaches every later pass as the characters it is.
|
|
431
|
+
*
|
|
432
|
+
* @param {string} line - Text with no escapes in it, on one line.
|
|
433
|
+
* @param {boolean} [lineStart] - Whether the text starts its line, where a
|
|
434
|
+
* list marker, a heading underline or an ordered-list number would be read.
|
|
435
|
+
* @returns {string} The text, safe in a paragraph.
|
|
436
|
+
*/
|
|
437
|
+
function escapeRoman(line, lineStart = true) {
|
|
438
|
+
const escaped = line.replace(/[\\`*_[\]<>!&#~|{}]/g, "\\$&");
|
|
439
|
+
return lineStart ?
|
|
440
|
+
escaped.replace(/^([-+=:])/, "\\$1").replace(/^(\d+)([.)])/, "$1\\$2")
|
|
441
|
+
: escaped;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The text of one line with an author's escapes removed, and where each
|
|
446
|
+
* remaining character was written.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} core - A line's text.
|
|
449
|
+
* @returns {{text: string, offsets: number[]}} The text, and for each of its
|
|
450
|
+
* UTF-16 units the offset in `core` its character came from.
|
|
451
|
+
*/
|
|
452
|
+
function unescapeRoman(core) {
|
|
453
|
+
let text = "";
|
|
454
|
+
const offsets = [];
|
|
455
|
+
for (let at = 0; at < core.length;) {
|
|
456
|
+
const escaped =
|
|
457
|
+
at + 1 < core.length && core[at] === "\\" && /[!-/:-@[-`{-~]/.test(core[at + 1]);
|
|
458
|
+
const point = String.fromCodePoint(
|
|
459
|
+
/** @type {number} */ (core.codePointAt(at + (escaped ? 1 : 0))),
|
|
460
|
+
);
|
|
461
|
+
text += point;
|
|
462
|
+
for (let unit = 0; unit < point.length; unit++) offsets.push(at + (escaped ? 1 : 0));
|
|
463
|
+
at += point.length + (escaped ? 1 : 0);
|
|
464
|
+
}
|
|
465
|
+
return { text, offsets };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Set a piece of a run's text: an author's escapes removed, the map applied,
|
|
470
|
+
* and the characters Markdown would read escaped again. Every line is set on
|
|
471
|
+
* its own, so the line count holds.
|
|
472
|
+
*
|
|
473
|
+
* @param {string} raw - The text as written.
|
|
474
|
+
* @param {import("./map-lexicon.mjs").Mapping|undefined} mapping - The run's
|
|
475
|
+
* map, face and lexicon.
|
|
476
|
+
* @param {boolean} lineStart - Whether `raw` starts its line.
|
|
477
|
+
* @returns {{text: string, pieces: Array<{offset: number, from: string, to: string, row: boolean, native?: boolean, uncapitalised?: string}>}}
|
|
478
|
+
* The set text, and each piece of it with the offset in `raw` it came from.
|
|
479
|
+
*/
|
|
480
|
+
function setText(raw, mapping, lineStart) {
|
|
481
|
+
const out = [];
|
|
482
|
+
const pieces = [];
|
|
483
|
+
let base = 0;
|
|
484
|
+
raw.split("\n").forEach((line, index) => {
|
|
485
|
+
const [, lead, core, breakMark, trail] = /** @type {RegExpExecArray} */ (
|
|
486
|
+
LINE_PARTS.exec(line)
|
|
487
|
+
);
|
|
488
|
+
if (core === "") out.push(line);
|
|
489
|
+
else {
|
|
490
|
+
const plain = unescapeRoman(core);
|
|
491
|
+
const mapped = applyMap(mapping?.map, plain.text, {
|
|
492
|
+
font: mapping?.font,
|
|
493
|
+
native: mapping?.native,
|
|
494
|
+
});
|
|
495
|
+
for (const piece of mapped.pieces)
|
|
496
|
+
pieces.push({
|
|
497
|
+
offset: base + lead.length + (plain.offsets[piece.at] ?? 0),
|
|
498
|
+
from: piece.from,
|
|
499
|
+
to: piece.to,
|
|
500
|
+
row: piece.row,
|
|
501
|
+
...(piece.native ? { native: true } : {}),
|
|
502
|
+
...(piece.uncapitalised === undefined ?
|
|
503
|
+
{}
|
|
504
|
+
: { uncapitalised: piece.uncapitalised }),
|
|
505
|
+
});
|
|
506
|
+
out.push(
|
|
507
|
+
lead +
|
|
508
|
+
escapeRoman(mapped.text, index > 0 || lineStart || lead !== "") +
|
|
509
|
+
breakMark +
|
|
510
|
+
trail,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
base += line.length + 1;
|
|
514
|
+
});
|
|
515
|
+
return { text: out.join("\n"), pieces };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* The parts of a range of text that no hole covers.
|
|
520
|
+
*
|
|
521
|
+
* @param {number} from - The range's first offset.
|
|
522
|
+
* @param {number} to - The offset past it.
|
|
523
|
+
* @param {ReadonlyMap<number, number>} holes - Marked spans inside it, start
|
|
524
|
+
* to end, by absolute offset.
|
|
525
|
+
* @returns {Array<[number, number]>} The uncovered parts, in order.
|
|
526
|
+
*/
|
|
527
|
+
function outsideHoles(from, to, holes) {
|
|
528
|
+
const parts = [];
|
|
529
|
+
let at = from;
|
|
530
|
+
for (const [start, end] of [...holes].sort((a, b) => a[0] - b[0])) {
|
|
531
|
+
if (end <= at || start >= to) continue;
|
|
532
|
+
if (start > at) parts.push([at, start]);
|
|
533
|
+
at = Math.max(at, end);
|
|
534
|
+
}
|
|
535
|
+
if (at < to) parts.push([at, to]);
|
|
536
|
+
return parts;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** `U+XXXX` for a code point. */
|
|
540
|
+
function codeName(point) {
|
|
541
|
+
return `U+${point.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** A parser for the block structure of a div's body. */
|
|
545
|
+
const blockParser = new MarkdownIt({ html: true });
|
|
546
|
+
|
|
547
|
+
/** What a block token is called in a finding. */
|
|
548
|
+
const BLOCK_WHAT = Object.freeze({
|
|
549
|
+
heading_open: "a heading",
|
|
550
|
+
bullet_list_open: "a list",
|
|
551
|
+
ordered_list_open: "a list",
|
|
552
|
+
blockquote_open: "a block quote",
|
|
553
|
+
code_block: "a code block",
|
|
554
|
+
table_open: "a table",
|
|
555
|
+
hr: "a rule",
|
|
556
|
+
html_block: "HTML",
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* One marked construct, with what rendering it needs beside its {@link Run}.
|
|
561
|
+
*
|
|
562
|
+
* @typedef {object} RunDetail
|
|
563
|
+
* @property {Run} run - The run.
|
|
564
|
+
* @property {{id: string, classes: string[], values: Array<[string, string]>}} own -
|
|
565
|
+
* What the construct carries besides the run keys.
|
|
566
|
+
* @property {Record<string, string>} keys - The run keys it states itself.
|
|
567
|
+
* @property {boolean} plain - Whether its text is set.
|
|
568
|
+
* @property {import("./script-fonts.mjs").Face|undefined} face - Its face.
|
|
569
|
+
* @property {string|undefined} generic - Its generic family's class.
|
|
570
|
+
* @property {import("./text-maps.mjs").TextMap|undefined} textMap - Its map.
|
|
571
|
+
* @property {import("./map-lexicon.mjs").Mapping|undefined} mapping - Its map,
|
|
572
|
+
* face and lexicon, which its text is set by.
|
|
573
|
+
* @property {Map<number, number>} holes - The marked spans inside it.
|
|
574
|
+
* @property {[number, number]|undefined} body - A div's or fence's body, as
|
|
575
|
+
* offsets: the first body line's start to the end of the last.
|
|
576
|
+
* @property {number|undefined} contentEnd - A span's offset of its closing `]`.
|
|
577
|
+
*/
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Read every marked construct in a source, and every fault in it.
|
|
581
|
+
*
|
|
582
|
+
* @param {string} source - Authored Markdown.
|
|
583
|
+
* @param {RunContext} ctx - The names.
|
|
584
|
+
* @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session
|
|
585
|
+
* scanner projections.
|
|
586
|
+
* @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>}}
|
|
587
|
+
* What {@link scanRuns} returns, and what {@link renderRuns} rewrites by.
|
|
588
|
+
*/
|
|
589
|
+
function analyseRuns(source, ctx, syntax) {
|
|
590
|
+
const text = String(source ?? "");
|
|
591
|
+
const lines = text.split("\n");
|
|
592
|
+
const starts = lineStarts(text);
|
|
593
|
+
const languages = ctx.languages ?? {};
|
|
594
|
+
const faces = ctx.faces ?? new Map();
|
|
595
|
+
const maps = ctx.maps ?? new Map();
|
|
596
|
+
const errors = [];
|
|
597
|
+
const warnings = [];
|
|
598
|
+
const footnotes = new Set(
|
|
599
|
+
[...text.matchAll(/^ {0,3}\[\^([^\]\s]+)\]:/gm)].map((match) => match[1]),
|
|
600
|
+
);
|
|
601
|
+
/** @param {{line: number, column: number}} at @param {string} message */
|
|
602
|
+
const report = (at, message) => errors.push({ line: at.line, column: at.column, message });
|
|
603
|
+
/** @param {Record<string, string>} values */
|
|
604
|
+
const runKeys = (values) =>
|
|
605
|
+
Object.fromEntries(Object.entries(values).filter(([key]) => RUN_KEYS.includes(key)));
|
|
606
|
+
|
|
607
|
+
// Every construct that states a run key, with where it sits.
|
|
608
|
+
const constructs = [];
|
|
609
|
+
const spans = (syntax ? syntax(text).spans : scanSpans(text)).spans;
|
|
610
|
+
for (const span of spans) {
|
|
611
|
+
const keys = Object.fromEntries(span.attrs.filter(([key]) => RUN_KEYS.includes(key)));
|
|
612
|
+
if (!Object.keys(keys).length) continue;
|
|
613
|
+
const positions = span.attributePositions ?? {};
|
|
614
|
+
constructs.push({
|
|
615
|
+
kind: "span",
|
|
616
|
+
keys,
|
|
617
|
+
node: span,
|
|
618
|
+
line0: span.line - 1,
|
|
619
|
+
last: span.line - 1,
|
|
620
|
+
at: (key, part) => positions[key]?.[part] ?? { line: span.line, column: span.column },
|
|
621
|
+
where: { line: span.line, column: span.column, start: span.start, end: span.end },
|
|
622
|
+
own: {
|
|
623
|
+
id: span.id ?? "",
|
|
624
|
+
classes: (span.attrs.find(([key]) => key === "class")?.[1] ?? "")
|
|
625
|
+
.split(/\s+/)
|
|
626
|
+
.filter(Boolean),
|
|
627
|
+
values: span.attrs.filter(([key]) => !["id", "class", ...RUN_KEYS].includes(key)),
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const divs = new Map();
|
|
633
|
+
const collect = (offset, body) => {
|
|
634
|
+
const scanned = offset === 0 && syntax ? syntax(body).blocks : scanBlocks(body);
|
|
635
|
+
for (const block of scanned.blocks) {
|
|
636
|
+
const first = offset + block.start;
|
|
637
|
+
const last = offset + block.end;
|
|
638
|
+
const opening = lines[first];
|
|
639
|
+
const at = (key, part) => ({
|
|
640
|
+
line: first + 1,
|
|
641
|
+
column: attributeColumns(opening, key)[part],
|
|
642
|
+
});
|
|
643
|
+
const keys = runKeys(block.attributes);
|
|
644
|
+
if (block.name !== "div") {
|
|
645
|
+
for (const key of Object.keys(keys))
|
|
646
|
+
report(
|
|
647
|
+
at(key, "key"),
|
|
648
|
+
`\`${key}=\` is read on a span, a div or a poetry fence, not on a ${block.name} block`,
|
|
649
|
+
);
|
|
650
|
+
} else {
|
|
651
|
+
divs.set(first, block);
|
|
652
|
+
if (Object.keys(keys).length)
|
|
653
|
+
constructs.push({
|
|
654
|
+
kind: "block",
|
|
655
|
+
keys,
|
|
656
|
+
node: block,
|
|
657
|
+
line0: first,
|
|
658
|
+
last,
|
|
659
|
+
at,
|
|
660
|
+
where: {
|
|
661
|
+
line: first + 1,
|
|
662
|
+
column: opening.indexOf(":::") + 1,
|
|
663
|
+
start: first,
|
|
664
|
+
end: last,
|
|
665
|
+
},
|
|
666
|
+
own: {
|
|
667
|
+
id: block.id ?? "",
|
|
668
|
+
classes: block.classes ?? [],
|
|
669
|
+
values: Object.entries(block.attributes ?? {}).filter(
|
|
670
|
+
([key]) => !RUN_KEYS.includes(key),
|
|
671
|
+
),
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
if (BLOCK_CONTAINERS.includes(block.name))
|
|
676
|
+
collect(first + 1, lines.slice(first + 1, last).join("\n"));
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
collect(0, text);
|
|
680
|
+
|
|
681
|
+
const poems = new Map();
|
|
682
|
+
for (const poem of scanPoetry(text).blocks) {
|
|
683
|
+
poems.set(poem.start, poem);
|
|
684
|
+
const keys = runKeys(poem.attributes);
|
|
685
|
+
if (!Object.keys(keys).length) continue;
|
|
686
|
+
const opening = lines[poem.start];
|
|
687
|
+
constructs.push({
|
|
688
|
+
kind: "poetry",
|
|
689
|
+
keys,
|
|
690
|
+
node: poem,
|
|
691
|
+
line0: poem.start,
|
|
692
|
+
last: poem.end,
|
|
693
|
+
at: (key, part) => ({
|
|
694
|
+
line: poem.start + 1,
|
|
695
|
+
column: attributeColumns(opening, key)[part],
|
|
696
|
+
}),
|
|
697
|
+
where: {
|
|
698
|
+
line: poem.start + 1,
|
|
699
|
+
column: poem.indent.length + 1,
|
|
700
|
+
start: poem.start,
|
|
701
|
+
end: poem.end,
|
|
702
|
+
},
|
|
703
|
+
own: {
|
|
704
|
+
id: poem.id ?? "",
|
|
705
|
+
classes: poem.classes ?? [],
|
|
706
|
+
values: Object.entries(poem.attributes ?? {}).filter(
|
|
707
|
+
([key]) => !RUN_KEYS.includes(key),
|
|
708
|
+
),
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const containers = constructs.filter((construct) => construct.kind !== "span");
|
|
714
|
+
/** The containers around a construct, outermost first. */
|
|
715
|
+
const around = (construct) =>
|
|
716
|
+
containers
|
|
717
|
+
.filter(
|
|
718
|
+
(container) =>
|
|
719
|
+
container !== construct &&
|
|
720
|
+
container.line0 < construct.line0 &&
|
|
721
|
+
construct.line0 < container.last,
|
|
722
|
+
)
|
|
723
|
+
.sort((a, b) => a.line0 - b.line0);
|
|
724
|
+
|
|
725
|
+
/** A language's tag, or `undefined` when it resolves to none. */
|
|
726
|
+
const tagOf = (lang) =>
|
|
727
|
+
lang === undefined ? undefined
|
|
728
|
+
: Object.hasOwn(languages, lang) ? languages[lang].tag
|
|
729
|
+
: isLanguageTag(lang) ? lang
|
|
730
|
+
: undefined;
|
|
731
|
+
|
|
732
|
+
const faceNames = () => [...faces.keys(), ...Object.keys(GENERIC_FONTS)];
|
|
733
|
+
|
|
734
|
+
/** Check the keys a construct states itself. */
|
|
735
|
+
const checkOwn = (construct) => {
|
|
736
|
+
const { lang, font, map } = construct.keys;
|
|
737
|
+
const { at } = construct;
|
|
738
|
+
if (lang !== undefined && !Object.hasOwn(languages, lang)) {
|
|
739
|
+
if (isLanguageTag(lang)) {
|
|
740
|
+
const declared = Object.keys(languages).find((name) => withinOneEdit(lang, name));
|
|
741
|
+
if (declared) {
|
|
742
|
+
const where = at("lang", "value");
|
|
743
|
+
warnings.push({
|
|
744
|
+
line: where.line,
|
|
745
|
+
column: where.column,
|
|
746
|
+
message: `\`${lang}\` is read as a BCP 47 tag; did you mean the declared language \`${declared}\`?`,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
} else {
|
|
750
|
+
const near = nearest(lang, Object.keys(languages));
|
|
751
|
+
report(
|
|
752
|
+
at("lang", "value"),
|
|
753
|
+
`\`${lang}\` is not a declared language (\`languages\` in ${CONFIG}) or a BCP 47 tag` +
|
|
754
|
+
(near ? `; did you mean \`${near}\`?` : ""),
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (font !== undefined && !faces.has(font) && !Object.hasOwn(GENERIC_FONTS, font)) {
|
|
759
|
+
const near = nearest(font, faceNames());
|
|
760
|
+
report(
|
|
761
|
+
at("font", "value"),
|
|
762
|
+
`\`${font}\` is not a font: \`font=\` names ` +
|
|
763
|
+
(faces.size ?
|
|
764
|
+
"an installed face, or `mono`, `serif` or `sans`"
|
|
765
|
+
: `\`mono\`, \`serif\` or \`sans\`, or a face of ${FONTS_PACKAGE}, which this package has not installed`) +
|
|
766
|
+
(near ? `; did you mean \`${near}\`?` : ""),
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
if (map !== undefined && !maps.has(map)) {
|
|
770
|
+
const near = nearest(map, [...maps.keys()]);
|
|
771
|
+
report(
|
|
772
|
+
at("map", "value"),
|
|
773
|
+
`\`${map}\` is not a map: \`map=\` names the \`data.map.name\` of a mapping note` +
|
|
774
|
+
(near ? `; did you mean \`${near}\`?` : ""),
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
/** Each marked span inside a container, by absolute offset. */
|
|
780
|
+
const holesIn = (from, to) =>
|
|
781
|
+
new Map(
|
|
782
|
+
constructs
|
|
783
|
+
.filter(
|
|
784
|
+
(construct) =>
|
|
785
|
+
construct.kind === "span" &&
|
|
786
|
+
construct.where.start >= from &&
|
|
787
|
+
construct.where.start < to,
|
|
788
|
+
)
|
|
789
|
+
.map((construct) => [construct.where.start, construct.where.end]),
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Report the markup in inline text that must be plain.
|
|
794
|
+
*
|
|
795
|
+
* @param {string} inline - The text.
|
|
796
|
+
* @param {number} offset - Its offset in the source.
|
|
797
|
+
* @param {string} marking - How its construct is marked.
|
|
798
|
+
* @param {ReadonlyMap<number, number>} [holes] - Marked spans in it, by
|
|
799
|
+
* absolute offset.
|
|
800
|
+
*/
|
|
801
|
+
const plainInline = (inline, offset, marking, holes = new Map()) => {
|
|
802
|
+
const relative = new Map(
|
|
803
|
+
[...holes]
|
|
804
|
+
.filter(([start]) => start >= offset && start < offset + inline.length)
|
|
805
|
+
.map(([start, end]) => [start - offset, end - offset]),
|
|
806
|
+
);
|
|
807
|
+
for (const fault of inlineMarkup(inline, footnotes, relative))
|
|
808
|
+
report(
|
|
809
|
+
positionOf(starts, offset + fault.offset),
|
|
810
|
+
`text marked \`${marking}\` is plain text and holds no ${fault.what}`,
|
|
811
|
+
);
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Report each character a face does not draw, once per run, at the source
|
|
816
|
+
* character it came from.
|
|
817
|
+
*
|
|
818
|
+
* @param {Array<[number, number]>} parts - The text to check, as offsets.
|
|
819
|
+
* @param {RunDetail} detail - The run.
|
|
820
|
+
*/
|
|
821
|
+
const covered = (parts, detail) => {
|
|
822
|
+
const { face, textMap, mapping } = detail;
|
|
823
|
+
if (!face) return;
|
|
824
|
+
const said = new Set();
|
|
825
|
+
for (const [from, to] of parts) {
|
|
826
|
+
const lineStart = from === 0 || text[from - 1] === "\n";
|
|
827
|
+
for (const piece of setText(text.slice(from, to), mapping, lineStart).pieces) {
|
|
828
|
+
for (const char of piece.to) {
|
|
829
|
+
const point = /** @type {number} */ (char.codePointAt(0));
|
|
830
|
+
if (/\s/u.test(char) || face.draws.has(point)) continue;
|
|
831
|
+
const key = piece.row ? `${piece.from}\u0000${point}` : char;
|
|
832
|
+
if (said.has(key)) continue;
|
|
833
|
+
said.add(key);
|
|
834
|
+
const where = positionOf(starts, from + piece.offset);
|
|
835
|
+
if (piece.native)
|
|
836
|
+
report(
|
|
837
|
+
where,
|
|
838
|
+
`\`${piece.from}\` is written ${codeName(point)} by the lexicon of ` +
|
|
839
|
+
`\`${detail.run.lang}\` for the map \`${textMap?.name}\`, which the font ` +
|
|
840
|
+
`\`${face.slug}\` does not draw`,
|
|
841
|
+
);
|
|
842
|
+
else if (piece.row)
|
|
843
|
+
report(
|
|
844
|
+
where,
|
|
845
|
+
`\`${piece.from}\` maps to ${codeName(point)} in the map \`${textMap?.name}\`, ` +
|
|
846
|
+
`which the font \`${face.slug}\` does not draw`,
|
|
847
|
+
);
|
|
848
|
+
else
|
|
849
|
+
report(
|
|
850
|
+
where,
|
|
851
|
+
`\`${char}\` (${codeName(point)}) is not drawn by the font \`${face.slug}\`` +
|
|
852
|
+
(textMap ?
|
|
853
|
+
`, and the map \`${textMap.name}\` has no row for it`
|
|
854
|
+
: ""),
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Report each capitalised word whose native form the rows cannot
|
|
863
|
+
* capitalise, at the word.
|
|
864
|
+
*
|
|
865
|
+
* @param {Array<[number, number]>} parts - The text to check, as offsets.
|
|
866
|
+
* @param {RunDetail} detail - The run.
|
|
867
|
+
*/
|
|
868
|
+
const capitals = (parts, detail) => {
|
|
869
|
+
const { mapping } = detail;
|
|
870
|
+
if (!mapping?.native) return;
|
|
871
|
+
for (const [from, to] of parts) {
|
|
872
|
+
const lineStart = from === 0 || text[from - 1] === "\n";
|
|
873
|
+
for (const piece of setText(text.slice(from, to), mapping, lineStart).pieces)
|
|
874
|
+
if (piece.uncapitalised !== undefined)
|
|
875
|
+
report(
|
|
876
|
+
positionOf(starts, from + piece.offset),
|
|
877
|
+
uncapitalisedMessage(
|
|
878
|
+
piece.from,
|
|
879
|
+
piece.uncapitalised,
|
|
880
|
+
detail.run.lang,
|
|
881
|
+
mapping.map.name,
|
|
882
|
+
),
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Report each word of a mapped run that is not a word of its language, once
|
|
889
|
+
* per run, at its first character.
|
|
890
|
+
*
|
|
891
|
+
* @param {Array<[number, number]>} parts - The text to check, as offsets.
|
|
892
|
+
* @param {RunDetail} detail - The run.
|
|
893
|
+
*/
|
|
894
|
+
const lexical = (parts, detail) => {
|
|
895
|
+
const { lang, map } = detail.run;
|
|
896
|
+
if (lang === undefined || map === undefined || !maps.has(map)) return;
|
|
897
|
+
const book = ctx.wordBooks?.get(lang);
|
|
898
|
+
if (!book) return;
|
|
899
|
+
const said = new Set();
|
|
900
|
+
for (const [from, to] of parts) {
|
|
901
|
+
let base = from;
|
|
902
|
+
for (const line of text.slice(from, to).split("\n")) {
|
|
903
|
+
const plain = unescapeRoman(line);
|
|
904
|
+
for (const { offset, word } of unknownWords(plain.text, book, said))
|
|
905
|
+
report(
|
|
906
|
+
positionOf(starts, base + (plain.offsets[offset] ?? 0)),
|
|
907
|
+
unknownWordMessage(word, lang),
|
|
908
|
+
);
|
|
909
|
+
base += line.length + 1;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const runs = [];
|
|
915
|
+
const details = [];
|
|
916
|
+
for (const construct of constructs) {
|
|
917
|
+
checkOwn(construct);
|
|
918
|
+
const keys = Object.assign({}, ...around(construct).map((c) => c.keys), construct.keys);
|
|
919
|
+
const { lang, font, map } = keys;
|
|
920
|
+
// A face the run's map is not for, once the face and the map are each
|
|
921
|
+
// known, at the construct that states either.
|
|
922
|
+
const stated = construct.keys.map !== undefined || construct.keys.font !== undefined;
|
|
923
|
+
if (
|
|
924
|
+
stated &&
|
|
925
|
+
map !== undefined &&
|
|
926
|
+
maps.has(map) &&
|
|
927
|
+
font !== undefined &&
|
|
928
|
+
(faces.has(font) || Object.hasOwn(GENERIC_FONTS, font))
|
|
929
|
+
) {
|
|
930
|
+
const problem = fontProblem(maps.get(map), font);
|
|
931
|
+
if (problem)
|
|
932
|
+
report(
|
|
933
|
+
construct.at(construct.keys.map !== undefined ? "map" : "font", "value"),
|
|
934
|
+
problem,
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
const run = {
|
|
938
|
+
kind: construct.kind,
|
|
939
|
+
lang,
|
|
940
|
+
tag: tagOf(lang),
|
|
941
|
+
font,
|
|
942
|
+
map,
|
|
943
|
+
...construct.where,
|
|
944
|
+
};
|
|
945
|
+
runs.push(run);
|
|
946
|
+
const plain =
|
|
947
|
+
font !== undefined ||
|
|
948
|
+
map !== undefined ||
|
|
949
|
+
(lang !== undefined && Object.hasOwn(languages, lang));
|
|
950
|
+
const detail = {
|
|
951
|
+
run,
|
|
952
|
+
own: construct.own,
|
|
953
|
+
keys: construct.keys,
|
|
954
|
+
plain,
|
|
955
|
+
face: font === undefined ? undefined : faces.get(font),
|
|
956
|
+
generic: font === undefined ? undefined : GENERIC_FONTS[font],
|
|
957
|
+
textMap: map === undefined ? undefined : maps.get(map),
|
|
958
|
+
mapping: map === undefined ? undefined : mappingFor(ctx, { map, font, lang }),
|
|
959
|
+
holes: new Map(),
|
|
960
|
+
body: undefined,
|
|
961
|
+
contentEnd: construct.kind === "span" ? construct.node.contentEnd : undefined,
|
|
962
|
+
};
|
|
963
|
+
details.push(detail);
|
|
964
|
+
if (!plain) continue;
|
|
965
|
+
const marking = [
|
|
966
|
+
...(lang === undefined ? [] : [`lang=${lang}`]),
|
|
967
|
+
...(font === undefined ? [] : [`font=${font}`]),
|
|
968
|
+
...(map === undefined ? [] : [`map=${map}`]),
|
|
969
|
+
].join(" ");
|
|
970
|
+
const before = errors.length;
|
|
971
|
+
|
|
972
|
+
if (construct.kind === "span") {
|
|
973
|
+
const span = construct.node;
|
|
974
|
+
const inline = text.slice(span.start + 1, span.contentEnd);
|
|
975
|
+
plainInline(inline, span.start + 1, marking);
|
|
976
|
+
if (errors.length === before) {
|
|
977
|
+
lexical([[span.start + 1, span.contentEnd]], detail);
|
|
978
|
+
capitals([[span.start + 1, span.contentEnd]], detail);
|
|
979
|
+
covered([[span.start + 1, span.contentEnd]], detail);
|
|
980
|
+
}
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const first = construct.line0 + 1;
|
|
985
|
+
const last = construct.last;
|
|
986
|
+
const from = starts[first] ?? text.length;
|
|
987
|
+
const to = last > first ? starts[last] - 1 : from;
|
|
988
|
+
detail.body = [from, to];
|
|
989
|
+
detail.holes = holesIn(from, to);
|
|
990
|
+
|
|
991
|
+
if (construct.kind === "block") {
|
|
992
|
+
const body = lines.slice(first, last);
|
|
993
|
+
const said = (line, what) =>
|
|
994
|
+
report(
|
|
995
|
+
{ line: first + line + 1, column: /^ */.exec(body[line])[0].length + 1 },
|
|
996
|
+
`a div marked \`${marking}\` holds paragraphs and line breaks only, not ${what}`,
|
|
997
|
+
);
|
|
998
|
+
for (const token of blockParser.parse(body.join("\n"), {})) {
|
|
999
|
+
if (token.level !== 0 || !token.map) continue;
|
|
1000
|
+
const [top, past] = token.map;
|
|
1001
|
+
if (token.type === "paragraph_open") {
|
|
1002
|
+
const nested = body.slice(top, past).findIndex((line) => /^ *:::/.test(line));
|
|
1003
|
+
if (nested >= 0) said(top + nested, "a nested block");
|
|
1004
|
+
else if (
|
|
1005
|
+
body.slice(top, past).some((line) => /^ {0,3}\[\^[^\]]+\]:/.test(line))
|
|
1006
|
+
)
|
|
1007
|
+
said(top, "a footnote definition");
|
|
1008
|
+
else
|
|
1009
|
+
plainInline(
|
|
1010
|
+
body.slice(top, past).join("\n"),
|
|
1011
|
+
starts[first + top],
|
|
1012
|
+
marking,
|
|
1013
|
+
detail.holes,
|
|
1014
|
+
);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
if (token.type === "fence")
|
|
1018
|
+
said(
|
|
1019
|
+
top,
|
|
1020
|
+
token.info.trim().split(/\s/)[0] === "poetry" ?
|
|
1021
|
+
"a poetry fence"
|
|
1022
|
+
: "a code fence",
|
|
1023
|
+
);
|
|
1024
|
+
else if (Object.hasOwn(BLOCK_WHAT, token.type)) said(top, BLOCK_WHAT[token.type]);
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
for (let line = first; line < last; line++)
|
|
1028
|
+
plainInline(lines[line], starts[line], marking, detail.holes);
|
|
1029
|
+
}
|
|
1030
|
+
if (errors.length === before) {
|
|
1031
|
+
lexical(outsideHoles(from, to, detail.holes), detail);
|
|
1032
|
+
capitals(outsideHoles(from, to, detail.holes), detail);
|
|
1033
|
+
covered(outsideHoles(from, to, detail.holes), detail);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const order = (a, b) => a.line - b.line || a.column - b.column;
|
|
1038
|
+
runs.sort(order);
|
|
1039
|
+
details.sort((a, b) => order(a.run, b.run));
|
|
1040
|
+
errors.sort(order);
|
|
1041
|
+
warnings.sort(order);
|
|
1042
|
+
return { runs, details, errors, warnings, spans, divs, poems };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Find the spans, divs and poetry fences marked with `lang=`, `font=` or
|
|
1047
|
+
* `map=`, and every fault in their markup and their text.
|
|
1048
|
+
*
|
|
1049
|
+
* @param {string} source - Authored Markdown: a note body, or a whole file
|
|
1050
|
+
* whose frontmatter is blanked.
|
|
1051
|
+
* @param {RunContext} [ctx] - The configured languages, the installed faces
|
|
1052
|
+
* and the maps. Without them every `lang=` must be a tag, every face and
|
|
1053
|
+
* map is unknown, and only `mono`, `serif` and `sans` are fonts.
|
|
1054
|
+
* @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session
|
|
1055
|
+
* scanner projections, so the source's spans and blocks are scanned once.
|
|
1056
|
+
* @returns {{runs: Run[], errors: Array<{line: number, column: number, message: string}>, warnings: Array<{line: number, column: number, message: string}>}}
|
|
1057
|
+
* The runs in source order, and each finding at its 1-based position. A
|
|
1058
|
+
* warning is a `lang=` read as a tag that sits within one edit of a
|
|
1059
|
+
* declared name.
|
|
1060
|
+
*/
|
|
1061
|
+
export function scanRuns(source, ctx = {}, syntax) {
|
|
1062
|
+
const { runs, errors, warnings } = analyseRuns(source, ctx, syntax);
|
|
1063
|
+
return { runs, errors, warnings };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* An attribute value as the shared attribute grammar reads it back.
|
|
1068
|
+
*
|
|
1069
|
+
* @param {string} value - The value.
|
|
1070
|
+
* @returns {string} The value, quoted when it holds anything but name characters.
|
|
1071
|
+
*/
|
|
1072
|
+
function attributeValue(value) {
|
|
1073
|
+
return /^[A-Za-z0-9_.:/@%+-]+$/.test(value) ? value : `"${value.replace(/[\\"]/g, "\\$&")}"`;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* The text of an attribute block: `lang` first, then the id and classes, and
|
|
1078
|
+
* the other attributes.
|
|
1079
|
+
*
|
|
1080
|
+
* @param {object} parts - What the block carries.
|
|
1081
|
+
* @param {string|undefined} parts.lang - The tag.
|
|
1082
|
+
* @param {string} parts.id - The `#id`, or "".
|
|
1083
|
+
* @param {string[]} parts.classes - Every class.
|
|
1084
|
+
* @param {Array<[string, string]>} parts.values - The other `key=value` pairs.
|
|
1085
|
+
* @returns {string} The text between the braces.
|
|
1086
|
+
*/
|
|
1087
|
+
function attributeText({ lang, id, classes, values }) {
|
|
1088
|
+
return [
|
|
1089
|
+
...(lang === undefined ? [] : [`lang=${attributeValue(lang)}`]),
|
|
1090
|
+
...(id ? [`#${id}`] : []),
|
|
1091
|
+
...classes.map((name) => `.${name}`),
|
|
1092
|
+
...values.map(([key, value]) => `${key}=${attributeValue(value)}`),
|
|
1093
|
+
].join(" ");
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** The class every run in one of the faces carries. */
|
|
1097
|
+
const SCRIPT_CLASS = "script";
|
|
1098
|
+
|
|
1099
|
+
/** The class a div in one of the faces carries, which the theme sets its block rules on. */
|
|
1100
|
+
const SCRIPT_BLOCK_CLASS = "script-block";
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* The attribute text a run is rewritten to, or `undefined` when it stays as
|
|
1104
|
+
* written.
|
|
1105
|
+
*
|
|
1106
|
+
* @param {RunDetail} detail - The run.
|
|
1107
|
+
* @param {boolean} search - Whether the target is the site, whose search
|
|
1108
|
+
* index a face's code points are kept out of.
|
|
1109
|
+
* @returns {string|undefined} The text between the braces.
|
|
1110
|
+
*/
|
|
1111
|
+
function rewrittenAttributes(detail, search) {
|
|
1112
|
+
const { run, own, keys, face, generic, textMap } = detail;
|
|
1113
|
+
const changes =
|
|
1114
|
+
run.font !== undefined ||
|
|
1115
|
+
run.map !== undefined ||
|
|
1116
|
+
(run.tag !== undefined && (keys.lang !== run.tag || run.kind === "poetry"));
|
|
1117
|
+
if (!changes) return undefined;
|
|
1118
|
+
const classes = [...own.classes];
|
|
1119
|
+
const values = [...own.values];
|
|
1120
|
+
if (face) {
|
|
1121
|
+
classes.push(SCRIPT_CLASS);
|
|
1122
|
+
if (run.kind === "block") classes.push(SCRIPT_BLOCK_CLASS);
|
|
1123
|
+
classes.push(face.class);
|
|
1124
|
+
if (run.kind !== "span" && face.direction === "rtl") values.push(["dir", "rtl"]);
|
|
1125
|
+
if (search) values.push(["data-pagefind-ignore", "true"]);
|
|
1126
|
+
} else if (generic) classes.push(generic);
|
|
1127
|
+
if (run.map !== undefined && !textMap) return undefined;
|
|
1128
|
+
return attributeText({ lang: run.tag, id: own.id, classes, values });
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Rewrite text marked with `lang=`, `font=` and `map=` into the plain span,
|
|
1133
|
+
* div and poetry grammar the rest of each chain renders, one line for one line.
|
|
1134
|
+
*
|
|
1135
|
+
* - **The map, if any, is applied first.** Characters Markdown would read are
|
|
1136
|
+
* then backslash-escaped, so they reach every later pass as characters.
|
|
1137
|
+
* - **The construct carries** the language's tag as `lang`, the face's class
|
|
1138
|
+
* (with `script`, and `script-block` on a div) or the generic family's
|
|
1139
|
+
* class. On the site, a face's construct also carries
|
|
1140
|
+
* `data-pagefind-ignore`, since site search cannot read its code points;
|
|
1141
|
+
* Foundry and the book receive no search marker.
|
|
1142
|
+
* - **A div or fence in a right-to-left face** carries `dir=rtl`. No direction
|
|
1143
|
+
* mark, `<bdo>` or `<bdi>` is ever written: the code points carry their own
|
|
1144
|
+
* direction.
|
|
1145
|
+
*
|
|
1146
|
+
* A run with a finding is left as written; {@link scanRuns} reports it.
|
|
1147
|
+
*
|
|
1148
|
+
* @param {string} source - Authored Markdown: a note body.
|
|
1149
|
+
* @param {RunContext} [ctx] - The configured languages, faces and maps.
|
|
1150
|
+
* @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session
|
|
1151
|
+
* scanner projections.
|
|
1152
|
+
* @param {{target?: "site"|"foundry"|"book"}} [options] - The target the
|
|
1153
|
+
* Markdown is rendered for; only `site` writes the search marker.
|
|
1154
|
+
* @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>}}
|
|
1155
|
+
* The rewritten Markdown, and the findings of {@link scanRuns}.
|
|
1156
|
+
*/
|
|
1157
|
+
export function renderRuns(source, ctx = {}, syntax, options = {}) {
|
|
1158
|
+
const search = options.target === "site";
|
|
1159
|
+
const text = String(source ?? "");
|
|
1160
|
+
if (!/\b(?:lang|font|map)=/.test(text)) return { markdown: text, errors: [] };
|
|
1161
|
+
const { details, errors } = analyseRuns(text, ctx, syntax);
|
|
1162
|
+
if (!details.length) return { markdown: text, errors };
|
|
1163
|
+
const lines = text.split("\n");
|
|
1164
|
+
const starts = lineStarts(text);
|
|
1165
|
+
|
|
1166
|
+
/** Whether a finding sits inside a run. */
|
|
1167
|
+
const faulted = (run) => {
|
|
1168
|
+
const from = (run.line - 1) * 1e6 + run.column;
|
|
1169
|
+
const last = run.kind === "span" ? positionOf(starts, run.end).line : run.end + 1;
|
|
1170
|
+
const to = last * 1e6;
|
|
1171
|
+
return errors.some((error) => {
|
|
1172
|
+
const at = (error.line - 1) * 1e6 + error.column;
|
|
1173
|
+
return at >= from && at < to;
|
|
1174
|
+
});
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
/** @type {Array<{from: number, to: number, text: string}>} */
|
|
1178
|
+
const edits = [];
|
|
1179
|
+
|
|
1180
|
+
for (const detail of details) {
|
|
1181
|
+
const { run } = detail;
|
|
1182
|
+
if (faulted(run)) continue;
|
|
1183
|
+
const attributes = rewrittenAttributes(detail, search);
|
|
1184
|
+
if (run.kind === "span") {
|
|
1185
|
+
const contentEnd = /** @type {number} */ (detail.contentEnd);
|
|
1186
|
+
let inner = text.slice(run.start + 1, contentEnd);
|
|
1187
|
+
if (detail.plain)
|
|
1188
|
+
inner = setText(
|
|
1189
|
+
inner,
|
|
1190
|
+
detail.mapping,
|
|
1191
|
+
run.start === 0 || text[run.start - 1] === "\n",
|
|
1192
|
+
).text;
|
|
1193
|
+
else if (attributes === undefined) continue;
|
|
1194
|
+
const braces = attributes ?? text.slice(contentEnd + 2, run.end - 1);
|
|
1195
|
+
edits.push({ from: run.start, to: run.end, text: `[${inner}]{${braces}}` });
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
if (attributes !== undefined) {
|
|
1199
|
+
const open = lines[run.start].indexOf("{");
|
|
1200
|
+
const close = lines[run.start].lastIndexOf("}");
|
|
1201
|
+
edits.push({
|
|
1202
|
+
from: starts[run.start] + open + 1,
|
|
1203
|
+
to: starts[run.start] + close,
|
|
1204
|
+
text: attributes,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
if (detail.plain && detail.body)
|
|
1208
|
+
for (const [from, to] of outsideHoles(detail.body[0], detail.body[1], detail.holes)) {
|
|
1209
|
+
const lineStart = from === 0 || text[from - 1] === "\n";
|
|
1210
|
+
const set = setText(text.slice(from, to), detail.mapping, lineStart).text;
|
|
1211
|
+
if (set !== text.slice(from, to)) edits.push({ from, to, text: set });
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
let markdown = text;
|
|
1216
|
+
for (const edit of edits.sort((a, b) => b.from - a.from))
|
|
1217
|
+
markdown = markdown.slice(0, edit.from) + edit.text + markdown.slice(edit.to);
|
|
1218
|
+
return { markdown, errors };
|
|
1219
|
+
}
|