@heroiclands/package-build 25.3.0 → 25.5.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 (62) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +1 -1
  3. package/assets/styles/content-base.css +32 -0
  4. package/docs/api.md +30 -6
  5. package/docs/authoring/links-and-markup.md +143 -53
  6. package/docs/commands.md +8 -2
  7. package/docs/content-format.md +14 -6
  8. package/docs/diagnostics.md +12 -11
  9. package/docs/encoding-core.md +18 -4
  10. package/docs/guides/writing-in-invented-scripts.md +83 -37
  11. package/docs/reference/format-details.md +10 -11
  12. package/docs/reference/note-types.md +15 -15
  13. package/docs/reference/theme-pages.md +26 -18
  14. package/engine/anchors.mjs +14 -0
  15. package/engine/code-fences.mjs +15 -6
  16. package/engine/content-blocks.mjs +4 -2
  17. package/engine/content-figures.mjs +19 -5
  18. package/engine/content-footnotes.mjs +69 -1
  19. package/engine/content-index.mjs +1 -1
  20. package/engine/content-render.mjs +59 -43
  21. package/engine/content-scripts.mjs +301 -58
  22. package/engine/content-scroll.mjs +8 -3
  23. package/engine/content-spans.mjs +107 -6
  24. package/engine/content-verse.mjs +338 -198
  25. package/engine/encoding-standard.mjs +1 -0
  26. package/engine/encoding-template.mjs +5 -2
  27. package/engine/encoding.mjs +19 -14
  28. package/engine/first-use.mjs +43 -12
  29. package/engine/heading-attributes.mjs +2 -1
  30. package/engine/journals.mjs +8 -1
  31. package/engine/markdown-blocks.mjs +3 -2
  32. package/engine/markdownlint-run.mjs +190 -5
  33. package/engine/markup-validation.mjs +17 -0
  34. package/engine/name-markup.mjs +1 -0
  35. package/engine/note-vocabulary.mjs +2 -2
  36. package/engine/pdf-render.mjs +34 -23
  37. package/engine/prose-lint.mjs +14 -2
  38. package/engine/roman-characters.mjs +32 -10
  39. package/engine/roman-run.mjs +3 -0
  40. package/engine/roman-verse.mjs +1 -0
  41. package/engine/run-rows.mjs +1 -1
  42. package/engine/sql-tables.mjs +1 -1
  43. package/engine/verse-format.mjs +89 -0
  44. package/engine/verse-metre.mjs +18 -9
  45. package/engine/verse-regions.mjs +190 -0
  46. package/engine/wikilink-pattern.mjs +31 -0
  47. package/engine/wikilink-syntax.mjs +2 -16
  48. package/package.json +1 -1
  49. package/site-theme/static/css/style.css +24 -0
  50. package/types/engine/code-fences.d.mts +1 -3
  51. package/types/engine/content-footnotes.d.mts +23 -0
  52. package/types/engine/content-scripts.d.mts +4 -3
  53. package/types/engine/content-scroll.d.mts +1 -1
  54. package/types/engine/content-spans.d.mts +3 -1
  55. package/types/engine/content-verse.d.mts +75 -75
  56. package/types/engine/name-markup.d.mts +5 -0
  57. package/types/engine/roman-run.d.mts +3 -1
  58. package/types/engine/verse-format.d.mts +10 -0
  59. package/types/engine/verse-metre.d.mts +4 -4
  60. package/types/engine/verse-regions.d.mts +82 -0
  61. package/types/engine/wikilink-pattern.d.mts +17 -0
  62. 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)
@@ -107,12 +114,21 @@ const CONTAINER_PREFIX = /^(?:[ \t]{0,3}>[ \t]?|[ \t]{0,3}(?:[-*+]|\d{1,9}[.)])[
107
114
  */
108
115
  export function misplacedFootnoteDefinitions(source) {
109
116
  const lines = String(source ?? "").split("\n");
117
+ const verseLines = new Set(
118
+ verseRegions(String(source ?? "")).flatMap((region) =>
119
+ Array.from({ length: region.end - region.start + 1 }, (_, i) => region.start + i),
120
+ ),
121
+ );
110
122
  const errors = [];
111
123
  let fence = null;
112
124
  // Inside the continuation of a genuine top-level definition: a blank
113
125
  // line or a four-space indent belongs to it, not to a new mistake.
114
126
  let continuation = false;
115
127
  for (let i = 0; i < lines.length; i++) {
128
+ if (verseLines.has(i)) {
129
+ continuation = false;
130
+ continue;
131
+ }
116
132
  const line = lines[i];
117
133
  const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line);
118
134
  if (fence) {
@@ -240,3 +256,55 @@ export function footnoteFindings(source) {
240
256
  (a, b) => a.line - b.line || a.column - b.column,
241
257
  );
242
258
  }
259
+
260
+ /**
261
+ * Labels defined at the top level of this note.
262
+ * @param {string} source - Complete authored note Markdown.
263
+ * @returns {Set<string>} Same-note footnote labels.
264
+ */
265
+ export function footnoteLabels(source) {
266
+ if (!source.includes("[^")) return new Set();
267
+ return new Set(
268
+ [...separateFootnotes(source).definitions.matchAll(/^\[\^([^\]\s]+)\]:/gm)].map(
269
+ (match) => match[1],
270
+ ),
271
+ );
272
+ }
273
+
274
+ /**
275
+ * Actual defined references, excluding literal code, escapes and metadata.
276
+ * @param {string} source - Full note or raw inline fragment.
277
+ * @param {object} [options] - Definition context and source grammar.
278
+ * @param {ReadonlySet<string>} [options.definitions] - Same-note labels; derived from source when omitted.
279
+ * @param {boolean} [options.inline] - Treat all source as inline verse/prose.
280
+ * @returns {Array<{from:number,to:number,key:string,text:string}>} Original UTF-16 source ranges.
281
+ */
282
+ export function footnoteReferences(
283
+ source,
284
+ { definitions = footnoteLabels(source), inline = false } = {},
285
+ ) {
286
+ if (!definitions.size || !source.includes("[^")) return [];
287
+ const protectedRanges = inlineSyntaxRanges(source).filter(
288
+ ([from]) => source[from] !== "{" || source[from - 1] === "]",
289
+ );
290
+ const code = inline ? [] : codeRegions(source);
291
+ return [...source.matchAll(/\[\^([^\]\s]+)\]/g)].flatMap((match) => {
292
+ const from = match.index,
293
+ to = from + match[0].length;
294
+ const escapes = /\\+$/.exec(source.slice(0, from))?.[0].length ?? 0;
295
+ if (
296
+ !definitions.has(match[1]) ||
297
+ escapes % 2 ||
298
+ protectedRanges.some(([first, past]) => from >= first && from < past) ||
299
+ code.some((range) => from >= range.start && from < range.end)
300
+ )
301
+ return [];
302
+ if (
303
+ !inline &&
304
+ source[to] === ":" &&
305
+ /^[ \t]*$/.test(source.slice(source.lastIndexOf("\n", from - 1) + 1, from))
306
+ )
307
+ return [];
308
+ return [{ from, to, key: match[1], text: match[0] }];
309
+ });
310
+ }
@@ -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 { inlineVerse, scanVerse } 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,48 +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, { checkWorks: false }).blocks[0];
136
- if (!poem) continue;
137
- const paired = state.env.verses?.get(token.content.replace(/\n$/, ""));
138
- const model = paired?.model ?? poem.model;
139
- token.meta.verse = poem;
140
- token.meta.versePaired = paired;
141
- const parse = (text) => {
142
- const children = [];
143
- state.md.inline.parse(inlineVerse(text), state.md, state.env, children);
144
- return children;
145
- };
146
- token.meta.verseGroups = model.groups.map((group) =>
147
- group.cantoBreak ?
148
- { ...group, children: group.text ? parse(group.text) : [] }
149
- : {
150
- ...group,
151
- lines: group.lines.map((line) => ({
152
- ...line,
153
- children: parse(line.text),
154
- ...(paired ?
155
- { romanChildren: parse(line.romanText ?? line.text) }
156
- : {}),
157
- })),
158
- },
159
- );
170
+ if (token.type !== "verse_div") continue;
171
+ parseVerseContent(token, state.md, state.env, token.meta.verseRegion);
160
172
  }
161
173
  });
162
174
  }
@@ -384,7 +396,8 @@ export function createRenderer(target, options = {}) {
384
396
  use("footnotes", footnotePlugin);
385
397
  if (book) {
386
398
  use("alerts", alertMarkdownPlugin);
387
- use("spans", spanMarkdownPlugin);
399
+ use("spans", spanMarkdownPlugin, { verseInline: quotedVerse });
400
+ use("verse-div", verseDivPlugin);
388
401
  use("verse-inline", verseInlinePlugin);
389
402
  use("script-emphasis", scriptEmphasisPlugin);
390
403
  use("definition-lists", deflistPlugin);
@@ -456,6 +469,7 @@ export function createRenderer(target, options = {}) {
456
469
  renderInline,
457
470
  },
458
471
  ).markdown;
472
+ text = renderVerseQuotes(text);
459
473
  if (target === "web") text = renderSpans(text).markdown;
460
474
  return target === "web" ? renderWithheldSections(text) : text;
461
475
  };
@@ -476,6 +490,8 @@ export function createRenderer(target, options = {}) {
476
490
  )
477
491
  ),
478
492
  renderInline:
479
- book ? unsupported : (markdown) => restore(md.renderInline(String(markdown ?? ""))),
493
+ book ? unsupported : (
494
+ (markdown) => restore(md.renderInline(renderVerseQuotes(String(markdown ?? ""))))
495
+ ),
480
496
  };
481
497
  }