@heroiclands/package-build 25.2.1 → 25.3.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.
@@ -34,9 +34,8 @@
34
34
  *
35
35
  * Span, div, verse and scroll scanners retain original UTF-16 source offsets.
36
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.
37
+ * belong to a script written vertically. An encoded verse derives native and
38
+ * Roman presentations from one authored text and retains explicit boundaries.
40
39
  * @module
41
40
  */
42
41
 
@@ -44,7 +43,7 @@ import MarkdownIt from "markdown-it";
44
43
  import { blockTokens } from "./markdown-blocks.mjs";
45
44
 
46
45
  import { scanBlocks, BLOCK_CONTAINERS } from "./content-blocks.mjs";
47
- import { scanVerse, hasColumns, verseLine, verseGroups, STANZA_BREAK } from "./content-verse.mjs";
46
+ import { scanVerse } from "./content-verse.mjs";
48
47
  import { scanSpans } from "./content-spans.mjs";
49
48
  import { scanScrolls, scrollParagraphs } from "./content-scroll.mjs";
50
49
  import { directionsFromRecords } from "./script-direction.mjs";
@@ -1055,11 +1054,6 @@ function analyseRuns(source, ctx, syntax) {
1055
1054
  nativeAt(construct),
1056
1055
  "`.nativefont` is not an accepted class; select an encoding with `enc=`",
1057
1056
  );
1058
- if (native(construct) && construct.kind === "verse" && hasColumns(construct.node))
1059
- report(
1060
- construct.at("enc", "value"),
1061
- "a verse with cols>1 selects encoding on individual runs, not the fence",
1062
- );
1063
1057
 
1064
1058
  if (lang !== undefined && !Object.hasOwn(languages, lang)) {
1065
1059
  if (isLanguageTag(lang)) {
@@ -1203,41 +1197,47 @@ function analyseRuns(source, ctx, syntax) {
1203
1197
 
1204
1198
  const verseMarkers = new Map();
1205
1199
  const verseContexts = new Map();
1206
- const verseReadings = new Map();
1207
- for (const poem of scanVerse(text).blocks) {
1200
+ for (const poem of scanVerse(text, { checkWorks: false }).blocks) {
1208
1201
  for (let line = poem.start + 1; line < poem.end; line++) verseContexts.set(line, poem);
1209
- for (let line = poem.start + 1; line < poem.end; line++)
1210
- if (
1211
- STANZA_BREAK.test(lines[line].trim()) ||
1212
- /^---\s+\{\.canto\}$/.test(lines[line].trim())
1213
- )
1214
- verseMarkers.set(starts[line], starts[line] + lines[line].length);
1215
- if (hasColumns(poem)) {
1216
- for (const group of verseGroups(poem, lines)) {
1217
- if (group.stanzaBreak) continue;
1218
- for (const part of group.parts) {
1219
- verseReadings.set(part.line, part);
1220
- const start = starts[part.line];
1221
- if (part.prefixLength)
1222
- verseMarkers.set(
1223
- start + part.sourceColumn - 1,
1224
- start + part.textColumn - 1,
1225
- );
1226
- if (part.markerLength)
1227
- verseMarkers.set(
1228
- start + part.markerStart,
1229
- start + part.markerStart + part.markerLength,
1230
- );
1231
- }
1202
+ const bodyStart = starts[poem.start + 1];
1203
+ for (const [from, to] of poem.model.layout)
1204
+ verseMarkers.set(bodyStart + from, bodyStart + to);
1205
+ for (const match of poem.body.matchAll(WIKILINK)) {
1206
+ const bar = match[1].indexOf("|");
1207
+ if (bar < 0) {
1208
+ verseMarkers.set(
1209
+ bodyStart + match.index,
1210
+ bodyStart + match.index + match[0].length,
1211
+ );
1212
+ continue;
1232
1213
  }
1233
- } else
1234
- for (let line = poem.start + 1; line < poem.end; line++) {
1235
- const { markerStart, markerLength } = verseLine(lines[line]);
1236
- if (markerLength) {
1237
- const from = starts[line] + markerStart;
1238
- verseMarkers.set(from, from + markerLength);
1239
- }
1214
+ verseMarkers.set(bodyStart + match.index, bodyStart + match.index + 3 + bar);
1215
+ verseMarkers.set(
1216
+ bodyStart + match.index + match[0].indexOf("]]"),
1217
+ bodyStart + match.index + match[0].length,
1218
+ );
1219
+ }
1220
+ // Link destinations and footnote references remain Markdown; only a
1221
+ // link's visible label contributes text to the encoding projection.
1222
+ for (let at = 0; at < poem.body.length; at++) {
1223
+ if (poem.body[at] === "\\") {
1224
+ at++;
1225
+ continue;
1240
1226
  }
1227
+ if (poem.body[at] !== "[") continue;
1228
+ const end = closing(poem.body, at);
1229
+ if (end < 0) continue;
1230
+ const label = poem.body.slice(at + 1, end);
1231
+ if (/^\^[^\s\]]+$/.test(label) && footnotes.has(label.slice(1))) {
1232
+ verseMarkers.set(bodyStart + at, bodyStart + end + 1);
1233
+ at = end;
1234
+ } else if (poem.body[end + 1] === "(" && poem.body[at - 1] !== "!") {
1235
+ const past = closing(poem.body, end + 1);
1236
+ if (past < 0) continue;
1237
+ verseMarkers.set(bodyStart + at, bodyStart + at + 1);
1238
+ verseMarkers.set(bodyStart + end, bodyStart + past + 1);
1239
+ }
1240
+ }
1241
1241
  }
1242
1242
 
1243
1243
  /** Nested spans and verse markers excluded from a run, by absolute offset. */
@@ -1560,23 +1560,7 @@ function analyseRuns(source, ctx, syntax) {
1560
1560
  }
1561
1561
  return owners.get(detail);
1562
1562
  };
1563
- const wholeVerseReading = (detail) => {
1564
- if (detail.run.kind === "verse") return true;
1565
- if (detail.run.kind !== "span") return false;
1566
- const line = detail.run.line - 1;
1567
- const poem = verseContexts.get(line);
1568
- if (!poem) return false;
1569
- const end = starts[line] + lines[line].length;
1570
- if (detail.run.end > end) return false;
1571
- const reading = verseReadings.get(line);
1572
- const from =
1573
- hasColumns(poem) ? starts[line] + (reading?.textColumn ?? 1) - 1 : starts[line];
1574
- return (
1575
- from <= detail.run.start &&
1576
- /^[ \t.]*$/.test(text.slice(from, detail.run.start)) &&
1577
- /^[ \t\r]*$/.test(text.slice(detail.run.end, end))
1578
- );
1579
- };
1563
+
1580
1564
  const spanDetails = new Map(
1581
1565
  details
1582
1566
  .filter((detail) => detail.run.kind === "span")
@@ -1604,13 +1588,17 @@ function analyseRuns(source, ctx, syntax) {
1604
1588
  includeSpan: (span) =>
1605
1589
  spanDetails.has(span.start) && ownerOf(spanDetails.get(span.start)) === owner,
1606
1590
  };
1607
- const verse = wholeVerseReading(owner);
1591
+ const poem =
1592
+ owner.run.kind === "verse" ?
1593
+ poems.get(owner.run.start)
1594
+ : verseContexts.get(owner.run.line - 1);
1595
+ const verse = poem !== undefined;
1608
1596
  const projection =
1609
1597
  verse ?
1610
1598
  plainRomanVerse(text, {
1611
1599
  ...projectionOptions,
1612
- isCantoLine: (line) =>
1613
- STANZA_BREAK.test(line.trim()) || /^---\s+\{\.canto\}$/.test(line.trim()),
1600
+ model: poem.model,
1601
+ modelOffset: starts[poem.start + 1],
1614
1602
  })
1615
1603
  : plainRomanRun(text, projectionOptions);
1616
1604
  const before = errors.length;
@@ -1628,18 +1616,7 @@ function analyseRuns(source, ctx, syntax) {
1628
1616
  );
1629
1617
  for (const problem of projection.errors ?? [])
1630
1618
  report(positionOf(starts, problem.offset), problem.message);
1631
- if (!verse && verseContexts.has(owner.run.line - 1))
1632
- for (const match of projection.text.matchAll(/\//g)) {
1633
- if (
1634
- projection.text.lastIndexOf("⟦", match.index) >
1635
- projection.text.lastIndexOf("⟧", match.index)
1636
- )
1637
- continue;
1638
- report(
1639
- positionOf(starts, projection.offsets[match.index]),
1640
- "a verse fence uses authored line endings for verse boundaries; write no literal `/` inside it",
1641
- );
1642
- }
1619
+
1643
1620
  const book = ctx.wordBooks?.get(owner.run.lang);
1644
1621
  if (book) {
1645
1622
  const mask = projection.text.split("");
@@ -2026,11 +2003,10 @@ function setRange(text, from, to, detail, lineStart, book) {
2026
2003
 
2027
2004
  /**
2028
2005
  * Rewrite text marked with `lang=`, `font=` and `enc=` into the plain span,
2029
- * div and verse grammar the rest of each chain renders, one line for one line.
2006
+ * div and verse grammar the rest of each chain renders.
2030
2007
  *
2031
- * - **Numbered verse prefixes and indentation markers** stay outside run text.
2032
- * Each authored run chooses its own language and presentation; physical
2033
- * lines and source positions remain unchanged.
2008
+ * - **Verse layout markers** stay outside encoded text. A shared logical
2009
+ * structure retains authored source positions and both presentations.
2034
2010
  * - **The enc, if any, is applied first.** Characters Markdown would read are
2035
2011
  * then backslash-escaped, so they reach every later pass as characters.
2036
2012
  * - **Emphasis in a face** becomes an `<em>` or a `<strong>` carrying its
@@ -2057,7 +2033,7 @@ function setRange(text, from, to, detail, lineStart, book) {
2057
2033
  * scanner projections.
2058
2034
  * @param {{target?: "site"|"foundry"|"book"}} [options] - The target the
2059
2035
  * Markdown is rendered for; only `site` writes the search marker.
2060
- * @returns {{markdown: string, errors: Array<{file?: string, line: number, column: number, message: string}>, scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>}}
2036
+ * @returns {{markdown: string, errors: Array<{file?: string, line: number, column: number, message: string}>, scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>, verses: Map<string, {model: ReturnType<typeof import("./content-verse.mjs").parseVerse>, classes: string[], attributes: Record<string, string>}>}}
2061
2037
  * The rewritten Markdown, the findings of {@link scanRuns}, and each
2062
2038
  * rewritten scroll fence's layout, by its rewritten body.
2063
2039
  */
@@ -2067,11 +2043,12 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2067
2043
  const text = String(source ?? "");
2068
2044
  /** @type {Map<string, import("./content-scroll.mjs").ScrollLayout>} */
2069
2045
  const scrolls = new Map();
2046
+ const verses = new Map();
2070
2047
  if (!/\b(?:lang|font|enc)=|\bnativefont\b|\.(?:emph|strong)\b|⟦/.test(text))
2071
- return { markdown: text, errors: [], scrolls };
2048
+ return { markdown: text, errors: [], scrolls, verses };
2072
2049
  const { details, errors } = analyseRuns(text, ctx, syntax);
2073
2050
  if (!details.length)
2074
- return { markdown: text, errors: diagnosticColumns(text, errors), scrolls };
2051
+ return { markdown: text, errors: diagnosticColumns(text, errors), scrolls, verses };
2075
2052
  const lines = text.split("\n");
2076
2053
  const starts = lineStarts(text);
2077
2054
 
@@ -2090,6 +2067,18 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2090
2067
 
2091
2068
  /** @type {Array<{from: number, to: number, text: string}>} */
2092
2069
  const edits = [];
2070
+ const poems = scanVerse(text, { checkWorks: false }).blocks;
2071
+ const logicalRanges = poems.flatMap((poem) => {
2072
+ const base = starts[poem.start + 1];
2073
+ return poem.model.groups.flatMap((group) =>
2074
+ group.cantoBreak ? [] : group.lines.map((line) => [base + line.from, base + line.to]),
2075
+ );
2076
+ });
2077
+ const rangesWithin = (from, to) => {
2078
+ const cuts = logicalRanges.flat().filter((at) => at > from && at < to);
2079
+ const points = [from, ...new Set(cuts.sort((a, b) => a - b)), to];
2080
+ return points.slice(1).map((past, index) => [points[index], past]);
2081
+ };
2093
2082
 
2094
2083
  /**
2095
2084
  * A scroll fence's body set as one line per paragraph, and its layout.
@@ -2207,8 +2196,11 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2207
2196
  for (const [from, to] of detail.reference ?
2208
2197
  [detail.reference]
2209
2198
  : outsideHoles(run.start + 1, contentEnd, detail.holes)) {
2210
- const set = setRange(text, from, to, detail, false, book);
2211
- if (set !== text.slice(from, to)) edits.push({ from, to, text: set });
2199
+ for (const [first, past] of rangesWithin(from, to)) {
2200
+ const set = setRange(text, first, past, detail, false, book);
2201
+ if (set !== text.slice(first, past))
2202
+ edits.push({ from: first, to: past, text: set });
2203
+ }
2212
2204
  }
2213
2205
  continue;
2214
2206
  }
@@ -2239,9 +2231,12 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2239
2231
  }
2240
2232
  if (detail.plain && detail.body)
2241
2233
  for (const [from, to] of outsideHoles(detail.body[0], detail.body[1], detail.holes)) {
2242
- const lineStart = from === 0 || text[from - 1] === "\n";
2243
- const set = setRange(text, from, to, detail, lineStart, book);
2244
- if (set !== text.slice(from, to)) edits.push({ from, to, text: set });
2234
+ for (const [first, past] of rangesWithin(from, to)) {
2235
+ const lineStart = first === 0 || text[first - 1] === "\n";
2236
+ const set = setRange(text, first, past, detail, lineStart, book);
2237
+ if (set !== text.slice(first, past))
2238
+ edits.push({ from: first, to: past, text: set });
2239
+ }
2245
2240
  }
2246
2241
  }
2247
2242
 
@@ -2268,5 +2263,104 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2268
2263
  let markdown = text;
2269
2264
  for (const edit of edits.sort((a, b) => b.from - a.from))
2270
2265
  markdown = markdown.slice(0, edit.from) + edit.text + markdown.slice(edit.to);
2271
- return { markdown, errors: diagnosticColumns(text, errors), scrolls };
2266
+ // Models retain authored structure; a native script may draw the boundaries
2267
+ // as glyphs which must not be reparsed as Roman delimiters.
2268
+ const rewritten = scanVerse(markdown, { checkWorks: false }).blocks;
2269
+ const pairedEdits = [];
2270
+ for (const [index, poem] of poems.entries()) {
2271
+ const detail = details.find(
2272
+ (detail) => detail.run.kind === "verse" && detail.run.start === poem.start,
2273
+ );
2274
+ if (!detail || detail.run.enc === "default" || !detail.selection || faulted(detail.run))
2275
+ continue;
2276
+ const block = rewritten[index];
2277
+ if (!block) continue;
2278
+ const base = starts[poem.start + 1];
2279
+ const applyLine = (line) => {
2280
+ const patches = [];
2281
+ for (const edit of edits) {
2282
+ const positions = line.offsets.flatMap((offset, index) => {
2283
+ const sourceOffset = base + offset;
2284
+ return sourceOffset >= edit.from && sourceOffset < edit.to ? [index] : [];
2285
+ });
2286
+ if (positions.length)
2287
+ patches.push({ from: positions[0], to: positions.at(-1) + 1, text: edit.text });
2288
+ else if (edit.from === edit.to && edit.from === base + line.to)
2289
+ patches.push({ from: line.text.length, to: line.text.length, text: edit.text });
2290
+ }
2291
+ let result = line.text;
2292
+ for (const edit of patches.sort((a, b) => b.from - a.from))
2293
+ result = result.slice(0, edit.from) + edit.text + result.slice(edit.to);
2294
+ return result.replace(/\r?\n[ ]*/g, "").trim();
2295
+ };
2296
+ const rewriteSpanAttributes = (source, rewrite) => {
2297
+ const changes = scanSpans(source).spans.flatMap((span) => {
2298
+ const attrs = parseExtensionAttributes(
2299
+ source.slice(span.contentEnd + 2, span.end - 1),
2300
+ );
2301
+ if (!rewrite(attrs, span)) return [];
2302
+ return [
2303
+ {
2304
+ from: span.contentEnd + 2,
2305
+ to: span.end - 1,
2306
+ text: attributeText({
2307
+ lang: attrs.values.lang,
2308
+ id: attrs.id,
2309
+ classes: attrs.classes,
2310
+ values: Object.entries(attrs.values).filter(([key]) => key !== "lang"),
2311
+ }),
2312
+ },
2313
+ ];
2314
+ });
2315
+ let result = source;
2316
+ for (const change of changes.sort((a, b) => b.from - a.from))
2317
+ result = result.slice(0, change.from) + change.text + result.slice(change.to);
2318
+ return result;
2319
+ };
2320
+ const romanLine = (source) =>
2321
+ rewriteSpanAttributes(source, (attrs) => {
2322
+ const lang = attrs.values.lang;
2323
+ if (lang !== undefined) attrs.values.lang = ctx.languages?.[lang]?.tag ?? lang;
2324
+ delete attrs.values.enc;
2325
+ delete attrs.values.font;
2326
+ attrs.id = "";
2327
+ return true;
2328
+ });
2329
+ const model = structuredClone(poem.model);
2330
+ for (const group of model.groups) {
2331
+ if (group.cantoBreak) {
2332
+ group.text = detail.selection.encoding.controls.verseCanto ?? "";
2333
+ continue;
2334
+ }
2335
+ for (const line of group.lines) {
2336
+ line.romanText = romanLine(line.text);
2337
+ const ids = new Set(
2338
+ scanSpans(line.text)
2339
+ .spans.map((span) => span.id)
2340
+ .filter(Boolean),
2341
+ );
2342
+ line.text = rewriteSpanAttributes(applyLine(line), (attrs, span) => {
2343
+ if (!span.id || ids.has(span.id)) return false;
2344
+ attrs.id = "";
2345
+ return true;
2346
+ });
2347
+ }
2348
+ }
2349
+ const classes = [SCRIPT_CLASS, ...(detail.face ? [detail.face.class] : [])];
2350
+ const attributes = {
2351
+ ...(detail.run.tag ? { lang: detail.run.tag } : {}),
2352
+ ...(detail.face?.direction === "rtl" ? { dir: "rtl" } : {}),
2353
+ ...(search ? { "data-pagefind-ignore": "true" } : {}),
2354
+ };
2355
+ const body = `<!--verse-pair:${verses.size + 1}-->\n${block.body}`;
2356
+ verses.set(body, { model, classes, attributes });
2357
+ const rewrittenStarts = lineStarts(markdown);
2358
+ pairedEdits.push({
2359
+ at: rewrittenStarts[block.start + 1],
2360
+ text: `<!--verse-pair:${verses.size}-->\n`,
2361
+ });
2362
+ }
2363
+ for (const edit of pairedEdits.sort((a, b) => b.at - a.at))
2364
+ markdown = markdown.slice(0, edit.at) + edit.text + markdown.slice(edit.at);
2365
+ return { markdown, errors: diagnosticColumns(text, errors), scrolls, verses };
2272
2366
  }
@@ -293,12 +293,10 @@ export function scanSpans(source) {
293
293
  }
294
294
  // Verse fences preserve inline Markdown even though the block parser
295
295
  // normally treats every code fence as literal text.
296
- for (const poem of scanVerse(text).blocks) {
297
- for (let line = poem.start + 1; line < poem.end; line++) {
298
- const children = [];
299
- parser.inline.parse(lines[line], parser, env, children);
300
- collect(children, (pos) => starts[line] + pos);
301
- }
296
+ for (const poem of scanVerse(text, { checkWorks: false }).blocks) {
297
+ const children = [];
298
+ parser.inline.parse(poem.body, parser, env, children);
299
+ collect(children, (pos) => starts[poem.start + 1] + pos);
302
300
  }
303
301
  spans.sort((a, b) => a.start - b.start);
304
302
  errors.sort((a, b) => a.line - b.line || a.column - b.column);