@heroiclands/package-build 25.2.1 → 25.4.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 (54) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/assets/styles/content-base.css +97 -116
  4. package/docs/api.md +30 -6
  5. package/docs/authoring/links-and-markup.md +214 -126
  6. package/docs/commands.md +2 -2
  7. package/docs/content-format.md +31 -6
  8. package/docs/diagnostics.md +27 -27
  9. package/docs/guides/writing-in-invented-scripts.md +89 -83
  10. package/docs/reference/format-details.md +6 -7
  11. package/docs/reference/note-types.md +15 -15
  12. package/docs/reference/theme-pages.md +38 -57
  13. package/engine/anchors.mjs +14 -0
  14. package/engine/code-fences.mjs +15 -6
  15. package/engine/content-blocks.mjs +7 -4
  16. package/engine/content-figures.mjs +19 -5
  17. package/engine/content-footnotes.mjs +60 -1
  18. package/engine/content-index.mjs +1 -1
  19. package/engine/content-render.mjs +66 -48
  20. package/engine/content-scripts.mjs +357 -101
  21. package/engine/content-spans.mjs +76 -10
  22. package/engine/content-verse.mjs +687 -358
  23. package/engine/first-use.mjs +43 -12
  24. package/engine/heading-attributes.mjs +2 -1
  25. package/engine/journals.mjs +8 -1
  26. package/engine/markdown-blocks.mjs +3 -2
  27. package/engine/markup-validation.mjs +17 -0
  28. package/engine/name-markup.mjs +1 -0
  29. package/engine/note-vocabulary.mjs +2 -2
  30. package/engine/pdf-render.mjs +89 -130
  31. package/engine/prose-lint.mjs +14 -2
  32. package/engine/roman-verse.mjs +68 -57
  33. package/engine/run-rows.mjs +1 -1
  34. package/engine/sql-tables.mjs +1 -1
  35. package/engine/verse-format.mjs +77 -0
  36. package/engine/verse-metre.mjs +49 -47
  37. package/engine/verse-regions.mjs +181 -0
  38. package/engine/wikilink-pattern.mjs +31 -0
  39. package/engine/wikilink-syntax.mjs +2 -16
  40. package/package.json +1 -1
  41. package/site-theme/static/css/style.css +68 -96
  42. package/types/engine/code-fences.d.mts +1 -3
  43. package/types/engine/content-blocks.d.mts +3 -1
  44. package/types/engine/content-footnotes.d.mts +23 -0
  45. package/types/engine/content-scripts.d.mts +12 -7
  46. package/types/engine/content-spans.d.mts +3 -1
  47. package/types/engine/content-verse.d.mts +263 -82
  48. package/types/engine/name-markup.d.mts +5 -0
  49. package/types/engine/roman-verse.d.mts +7 -6
  50. package/types/engine/verse-format.d.mts +9 -0
  51. package/types/engine/verse-metre.d.mts +4 -4
  52. package/types/engine/verse-regions.d.mts +78 -0
  53. package/types/engine/wikilink-pattern.d.mts +17 -0
  54. package/types/engine/wikilink-syntax.d.mts +1 -41
@@ -18,6 +18,7 @@ import MarkdownIt from "markdown-it";
18
18
  import footnotePlugin from "markdown-it-footnote";
19
19
  import deflistPlugin from "markdown-it-deflist";
20
20
 
21
+ import { verseRegions } from "./verse-regions.mjs";
21
22
  import { slugify } from "./content-slug.mjs";
22
23
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
23
24
  import { HEADING_LINE, splitHeadingAttributes } from "./heading-attributes.mjs";
@@ -184,9 +185,20 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
184
185
  errors = [],
185
186
  ids = new Set();
186
187
  const counts = {};
188
+ const verse = verseRegions(String(source ?? ""));
189
+ const verseStarts = new Map(verse.map((region) => [region.start, region]));
190
+ const verseLines = new Set(
191
+ verse.flatMap((region) =>
192
+ Array.from(
193
+ { length: region.end - region.start + 1 },
194
+ (_, index) => region.start + index,
195
+ ),
196
+ ),
197
+ );
187
198
  let codeFence = null;
188
199
  const fault = (line, message) => errors.push({ line: line + 1, column: 1, message });
189
200
  for (let i = 0; i < lines.length; i++) {
201
+ if (verseLines.has(i)) continue;
190
202
  const fence = FENCE.exec(lines[i]);
191
203
  if (codeFence) {
192
204
  if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length)
@@ -233,7 +245,11 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
233
245
  let bodyEnd;
234
246
  const firstLine = lines[bodyStart];
235
247
  let kind;
236
- if (/^ {0,3}:::(?:[ \t]+\{.*\})?[ \t]*$/.test(firstLine)) {
248
+ if (verseStarts.has(bodyStart)) {
249
+ const region = verseStarts.get(bodyStart);
250
+ bodyEnd = region.end + 1;
251
+ kind = "poem";
252
+ } else if (/^ {0,3}:::(?:[ \t]+\{.*\})?[ \t]*$/.test(firstLine)) {
237
253
  let depth = 1,
238
254
  literal = null;
239
255
  bodyEnd = bodyStart + 1;
@@ -305,10 +321,7 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
305
321
  fault(bodyStart, "this block cannot be captioned");
306
322
  continue;
307
323
  }
308
- kind =
309
- first.type === "fence" && /^verse(?:\s|$)/.test(first.info) ?
310
- "poem"
311
- : figureKind(first, contents.trim(), resolveRole);
324
+ kind = figureKind(first, contents.trim(), resolveRole);
312
325
  }
313
326
  if (explicit) kind = explicit === "verse" ? "poem" : explicit;
314
327
  const id = attributes.id;
@@ -338,6 +351,7 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
338
351
  }
339
352
  let literal = null;
340
353
  for (let at = 0; at < lines.length; at++) {
354
+ if (verseLines.has(at)) continue;
341
355
  const fence = FENCE.exec(lines[at]);
342
356
  if (literal) {
343
357
  if (fence && fence[1][0] === literal[0] && fence[1].length >= literal.length)
@@ -29,7 +29,8 @@
29
29
  * @module
30
30
  */
31
31
 
32
- import { matchAllOutsideCode } from "./code-fences.mjs";
32
+ import { inlineSyntaxRanges, verseRegions } from "./verse-regions.mjs";
33
+ import { codeRegions, matchAllOutsideCode } from "./code-fences.mjs";
33
34
 
34
35
  /**
35
36
  * Where a character offset in `source` falls, as a 1-based line and column.
@@ -52,9 +53,15 @@ function lineColumn(source, offset) {
52
53
  export function separateFootnotes(source) {
53
54
  const lines = String(source ?? "").split("\n");
54
55
  const body = [...lines];
56
+ const verseLines = new Set(
57
+ verseRegions(String(source ?? "")).flatMap((region) =>
58
+ Array.from({ length: region.end - region.start + 1 }, (_, i) => region.start + i),
59
+ ),
60
+ );
55
61
  const definitions = [];
56
62
  let fence = null;
57
63
  for (let i = 0; i < lines.length; i++) {
64
+ if (verseLines.has(i)) continue;
58
65
  const marker = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
59
66
  if (fence) {
60
67
  if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length)
@@ -240,3 +247,55 @@ export function footnoteFindings(source) {
240
247
  (a, b) => a.line - b.line || a.column - b.column,
241
248
  );
242
249
  }
250
+
251
+ /**
252
+ * Labels defined at the top level of this note.
253
+ * @param {string} source - Complete authored note Markdown.
254
+ * @returns {Set<string>} Same-note footnote labels.
255
+ */
256
+ export function footnoteLabels(source) {
257
+ if (!source.includes("[^")) return new Set();
258
+ return new Set(
259
+ [...separateFootnotes(source).definitions.matchAll(/^\[\^([^\]\s]+)\]:/gm)].map(
260
+ (match) => match[1],
261
+ ),
262
+ );
263
+ }
264
+
265
+ /**
266
+ * Actual defined references, excluding literal code, escapes and metadata.
267
+ * @param {string} source - Full note or raw inline fragment.
268
+ * @param {object} [options] - Definition context and source grammar.
269
+ * @param {ReadonlySet<string>} [options.definitions] - Same-note labels; derived from source when omitted.
270
+ * @param {boolean} [options.inline] - Treat all source as inline verse/prose.
271
+ * @returns {Array<{from:number,to:number,key:string,text:string}>} Original UTF-16 source ranges.
272
+ */
273
+ export function footnoteReferences(
274
+ source,
275
+ { definitions = footnoteLabels(source), inline = false } = {},
276
+ ) {
277
+ if (!definitions.size || !source.includes("[^")) return [];
278
+ const protectedRanges = inlineSyntaxRanges(source).filter(
279
+ ([from]) => source[from] !== "{" || source[from - 1] === "]",
280
+ );
281
+ const code = inline ? [] : codeRegions(source);
282
+ return [...source.matchAll(/\[\^([^\]\s]+)\]/g)].flatMap((match) => {
283
+ const from = match.index,
284
+ to = from + match[0].length;
285
+ const escapes = /\\+$/.exec(source.slice(0, from))?.[0].length ?? 0;
286
+ if (
287
+ !definitions.has(match[1]) ||
288
+ escapes % 2 ||
289
+ protectedRanges.some(([first, past]) => from >= first && from < past) ||
290
+ code.some((range) => from >= range.start && from < range.end)
291
+ )
292
+ return [];
293
+ if (
294
+ !inline &&
295
+ source[to] === ":" &&
296
+ /^[ \t]*$/.test(source.slice(source.lastIndexOf("\n", from - 1) + 1, from))
297
+ )
298
+ return [];
299
+ return [{ from, to, key: match[1], text: match[0] }];
300
+ });
301
+ }
@@ -1568,7 +1568,7 @@ function dynamicPublicationBody(body) {
1568
1568
  const source = String(body ?? "");
1569
1569
  return (
1570
1570
  [...matchAllOutsideCode(source, EXPRESSION)].length > 0 ||
1571
- codeRegions(source, { spans: false, verseAsProse: false }).some(({ start, end }) => {
1571
+ codeRegions(source, { spans: false }).some(({ start, end }) => {
1572
1572
  const opener = FENCE_LINE.exec(source.slice(start, end).split("\n", 1)[0]);
1573
1573
  const language = opener && parseHeaderArgs(opener[3]).language;
1574
1574
  return language === "sql" || language === PAGE_LIST_LANGUAGE;
@@ -47,7 +47,7 @@
47
47
  * 2. **Captioned items** — each figure's wrapper and label. Its number, kind
48
48
  * and role come from the figure scan expansion made, so a figure is
49
49
  * numbered once; see {@link module:engine/content-figures.renderFigureBlocks}.
50
- * 3. **Named blocks, verse and scrolls** — `:::` blocks, verse fences and
50
+ * 3. **Named blocks, verse and scrolls** — `:::` blocks, verse divs and
51
51
  * `scroll` fences.
52
52
  * 4. **Alerts.**
53
53
  * 5. **Spans and withheld sections** — written here on the `web` target; the
@@ -79,7 +79,8 @@ import { imagePlugin } from "./content-images.mjs";
79
79
  import { renderRuns } from "./content-scripts.mjs";
80
80
  import { renderSpans, spanMarkdownPlugin } from "./content-spans.mjs";
81
81
  import { cartoucheFigures, renderSymbolSpans } from "./content-symbols.mjs";
82
- import { hasColumns, inlineVerse, scanVerse, verseGroups, verseStanzas } from "./content-verse.mjs";
82
+ import { inlineVerse, parseVerse, renderVerseQuotes } from "./content-verse.mjs";
83
+ import { verseDivPlugin } from "./verse-regions.mjs";
83
84
  import { replaceOutsideCode } from "./code-fences.mjs";
84
85
  import { dictionaryEntryPlugin } from "./dictionary-layout.mjs";
85
86
  import { headingAttributesPlugin, headingIdsPlugin } from "./heading-attributes.mjs";
@@ -115,50 +116,59 @@ export function highlightCode(code, language) {
115
116
  return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
116
117
  }
117
118
 
118
- /**
119
- * Parse the verse a `verse` fence holds as inline Markdown.
120
- *
121
- * A fence is literal to the block parser; a poem's lines are prose with
122
- * inline Markdown. The lines are parsed before footnote definitions are
123
- * collected, with the surrounding note's env, so a reference in a verse
124
- * resolves against definitions written outside the fence.
125
- *
126
- * @param {import("markdown-it")} md - Markdown parser to extend.
127
- */
119
+ /** Parse logical verse lines in the note's shared inline environment. */
120
+ function parseVerseContent(token, md, env, region) {
121
+ const body = token.type === "verse_inline" ? token.meta.text : token.content;
122
+ const inline = token.type === "verse_inline";
123
+ const paired = inline ? undefined : env.verses?.get(body);
124
+ const footnotes = new Set(Object.keys(env.footnotes?.refs ?? {}).map((key) => key.slice(1)));
125
+ const model = paired?.model ?? region.model ?? parseVerse(body, { inline, footnotes });
126
+ token.meta ??= {};
127
+ token.meta.verse = region;
128
+ token.meta.versePaired = paired;
129
+ const parse = (text) => {
130
+ const children = [];
131
+ md.inline.parse(inlineVerse(text), md, env, children);
132
+ return children;
133
+ };
134
+ token.meta.verseNotes = (model.notes ?? []).map((line) => ({
135
+ children: parse(line.romanText ?? line.text),
136
+ }));
137
+ token.meta.verseGroups = model.groups.map((group) =>
138
+ group.cantoBreak ?
139
+ {
140
+ ...group,
141
+ children: group.text ? parse(group.text) : [],
142
+ titleChildren: group.title ? parse(group.title) : [],
143
+ }
144
+ : {
145
+ ...group,
146
+ lines: group.lines.map((line) => ({
147
+ ...line,
148
+ children: parse(line.text),
149
+ ...(paired ? { romanChildren: parse(line.romanText ?? line.text) } : {}),
150
+ })),
151
+ },
152
+ );
153
+ }
154
+
155
+ /** An opaque quoted verse is parsed once, before footnote collection. */
156
+ function quotedVerse(token, state) {
157
+ const classes = (token.attrGet("class") ?? "")
158
+ .split(/\s+/)
159
+ .filter((name) => name && name !== "verse");
160
+ const attributes = Object.fromEntries(
161
+ (token.attrs ?? []).filter(([key]) => !["id", "class"].includes(key)),
162
+ );
163
+ parseVerseContent(token, state.md, state.env, { id: token.attrGet("id"), classes, attributes });
164
+ }
165
+
166
+ /** A verse div's raw body receives inline parsing without block interpretation. */
128
167
  function verseInlinePlugin(md) {
129
168
  md.core.ruler.after("inline", "verse_inline", (state) => {
130
169
  for (const token of state.tokens) {
131
- if (token.type !== "fence" || !/^verse(?:\s|$)/.test(token.info?.trim() || ""))
132
- continue;
133
- token.meta ??= {};
134
- const source = `${token.markup}${token.info}\n${token.content}${token.markup}`;
135
- const poem = scanVerse(source).blocks[0];
136
- if (hasColumns(poem)) {
137
- token.meta.verseGroups = verseGroups(poem, source.split("\n")).map((group) => {
138
- if (group.stanzaBreak) return group;
139
- return {
140
- ...group,
141
- parts: group.parts.map((part) => {
142
- const children = [];
143
- state.md.inline.parse(
144
- inlineVerse(part.text),
145
- state.md,
146
- state.env,
147
- children,
148
- );
149
- return { ...part, children };
150
- }),
151
- };
152
- });
153
- continue;
154
- }
155
- token.meta.verseStanzas = verseStanzas(token.content).map((stanza) =>
156
- stanza.map(({ text, level }) => {
157
- const children = [];
158
- state.md.inline.parse(inlineVerse(text), state.md, state.env, children);
159
- return { children, level, stanzaBreak: /^\*{3}$/.test(text) };
160
- }),
161
- );
170
+ if (token.type !== "verse_div") continue;
171
+ parseVerseContent(token, state.md, state.env, token.meta.verseRegion);
162
172
  }
163
173
  });
164
174
  }
@@ -386,7 +396,8 @@ export function createRenderer(target, options = {}) {
386
396
  use("footnotes", footnotePlugin);
387
397
  if (book) {
388
398
  use("alerts", alertMarkdownPlugin);
389
- use("spans", spanMarkdownPlugin);
399
+ use("spans", spanMarkdownPlugin, { verseInline: quotedVerse });
400
+ use("verse-div", verseDivPlugin);
390
401
  use("verse-inline", verseInlinePlugin);
391
402
  use("script-emphasis", scriptEmphasisPlugin);
392
403
  use("definition-lists", deflistPlugin);
@@ -433,8 +444,12 @@ export function createRenderer(target, options = {}) {
433
444
  const prepare = (markdown, env = {}) => {
434
445
  let text = String(markdown ?? "");
435
446
  if (preview) text = sourceMalformedAlerts(text);
436
- if (env.runs)
437
- text = renderRuns(text, env.runs, undefined, { target: RUN_TARGET[target] }).markdown;
447
+ let verses = env.verses;
448
+ if (env.runs) {
449
+ const marked = renderRuns(text, env.runs, undefined, { target: RUN_TARGET[target] });
450
+ text = marked.markdown;
451
+ verses = marked.verses;
452
+ }
438
453
  // A glyph and a cartouche are pictures on every target that draws
439
454
  // HTML, so they are written before any stage renders a fragment. The
440
455
  // buffer-only preview shows a glyph as the source it was written in
@@ -448,12 +463,13 @@ export function createRenderer(target, options = {}) {
448
463
  { resolveRole: env.resolveRole, renderInline },
449
464
  );
450
465
  text = renderAlerts(
451
- renderBlocks(figured.markdown, target, { renderInline }).markdown,
466
+ renderBlocks(figured.markdown, target, { renderInline, verses }).markdown,
452
467
  target,
453
468
  {
454
469
  renderInline,
455
470
  },
456
471
  ).markdown;
472
+ text = renderVerseQuotes(text);
457
473
  if (target === "web") text = renderSpans(text).markdown;
458
474
  return target === "web" ? renderWithheldSections(text) : text;
459
475
  };
@@ -474,6 +490,8 @@ export function createRenderer(target, options = {}) {
474
490
  )
475
491
  ),
476
492
  renderInline:
477
- book ? unsupported : (markdown) => restore(md.renderInline(String(markdown ?? ""))),
493
+ book ? unsupported : (
494
+ (markdown) => restore(md.renderInline(renderVerseQuotes(String(markdown ?? ""))))
495
+ ),
478
496
  };
479
497
  }