@heroiclands/package-build 24.0.4 → 24.1.1

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 (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +8 -0
  3. package/assets/styles/content-base.css +25 -10
  4. package/bin/content-commands.mjs +183 -15
  5. package/bin/package-build.mjs +5 -170
  6. package/docs/authoring/links-and-markup.md +215 -179
  7. package/docs/commands.md +88 -2
  8. package/docs/configuration.md +6 -3
  9. package/docs/content-format.md +4 -3
  10. package/docs/diagnostics.md +57 -29
  11. package/docs/guides/book.md +1 -1
  12. package/docs/guides/module-project.md +2 -2
  13. package/docs/guides/site.md +2 -2
  14. package/docs/guides/writing-in-invented-scripts.md +175 -125
  15. package/docs/reference/format-details.md +53 -35
  16. package/docs/reference/note-types.md +10 -10
  17. package/docs/reference/theme-pages.md +18 -9
  18. package/engine/build-assets.mjs +179 -0
  19. package/engine/calendar-artifacts.mjs +7 -3
  20. package/engine/compendiums.mjs +16 -11
  21. package/engine/content-render.mjs +23 -2
  22. package/engine/content-scripts.mjs +348 -140
  23. package/engine/content-session.mjs +62 -14
  24. package/engine/content-spans.mjs +13 -1
  25. package/engine/content-timings.mjs +67 -0
  26. package/engine/content-verse.mjs +158 -373
  27. package/engine/note-vocabulary.mjs +5 -5
  28. package/engine/pdf-render.mjs +63 -44
  29. package/engine/run-rows.mjs +18 -20
  30. package/engine/script-font-delivery.mjs +22 -15
  31. package/engine/verse-metre.mjs +36 -8
  32. package/engine/written-in.mjs +9 -13
  33. package/package.json +1 -1
  34. package/site-theme/static/css/style.css +19 -10
  35. package/types/engine/build-assets.d.mts +23 -0
  36. package/types/engine/calendar-artifacts.d.mts +5 -1
  37. package/types/engine/content-scripts.d.mts +18 -7
  38. package/types/engine/content-session.d.mts +19 -1
  39. package/types/engine/content-timings.d.mts +14 -0
  40. package/types/engine/content-verse.d.mts +37 -163
  41. package/types/engine/run-rows.d.mts +16 -9
  42. package/types/engine/script-font-delivery.d.mts +8 -4
  43. package/types/engine/verse-metre.d.mts +4 -3
@@ -2209,9 +2209,9 @@ export const NOTE_VOCABULARY = Object.freeze({
2209
2209
  },
2210
2210
  ]),
2211
2211
  describe:
2212
- "The script the language is written in, as the map and the face a run marked " +
2213
- "with its name alone takes; a run's own `map=` or `font=` overrides it. Only a " +
2214
- "skill of `subType: language` states it.",
2212
+ "The script the language is written in, as the default map and face selected " +
2213
+ "by a run with explicit `lang=` and `.nativefont`; a run's own `map=` or " +
2214
+ "`font=` overrides its half. Only a skill of `subType: language` states it.",
2215
2215
  },
2216
2216
  {
2217
2217
  name: "script",
@@ -2451,8 +2451,8 @@ export const NOTE_VOCABULARY = Object.freeze({
2451
2451
  },
2452
2452
  ]),
2453
2453
  describe:
2454
- "A mapping, which text marked `map=` with its `name` is rewritten by " +
2455
- "before it is set. See " +
2454
+ "A mapping, which native text marked with `lang=`, `.nativefont` and its " +
2455
+ "`map=` name is rewritten by before it is set. See " +
2456
2456
  "[mapping notes](format-details.md#mapping-notes).",
2457
2457
  },
2458
2458
  {
@@ -87,13 +87,11 @@ import { GENERIC_FONTS, renderRuns, scanRuns } from "./content-scripts.mjs";
87
87
  import { scanFigures } from "./content-figures.mjs";
88
88
  import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
89
89
  import {
90
- COLUMNS_CLASS,
91
- STANZA_BREAK,
90
+ hasColumns,
92
91
  lineNumber,
93
92
  rightToLeft,
94
93
  scanVerse,
95
- verseColumns,
96
- verseStanzas,
94
+ verseColumnCount,
97
95
  } from "./content-verse.mjs";
98
96
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
99
97
  import { panelRoman, scanScrolls, scrollPanels, scrollParagraphs } from "./content-scroll.mjs";
@@ -763,20 +761,28 @@ function renderBlock(tokens, i, out, ctx) {
763
761
  }
764
762
  if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info?.trim() || "")) {
765
763
  const opening = `${token.markup}${token.info}`;
766
- const poem = scanVerse(`${opening}\n${token.content}${token.markup}`).blocks[0];
764
+ const scanned = scanVerse(`${opening}\n${token.content}${token.markup}`);
765
+ // Findings are collected before rendering. Keep malformed verse
766
+ // literal rather than assigning invalid parts to display cells.
767
+ if (scanned.errors.length) {
768
+ out.push(rawBlock(token.content, token.info));
769
+ return 1;
770
+ }
771
+ const poem = scanned.blocks[0];
767
772
  const step = Number(poem?.attributes.numstep ?? 0) || 0;
768
773
  const content =
769
- poem?.classes.includes(COLUMNS_CLASS) ?
770
- verseGrid(token, poem, opening, step, ctx)
774
+ hasColumns(poem) ? verseGrid(token, poem, step, ctx)
771
775
  : step ? numberedVerse(token, step, ctx)
772
776
  : token.meta.verseStanzas
773
777
  .map((stanza) =>
774
- stanza
775
- .map(({ children, level }) => {
776
- const verse = renderInline({ children }, ctx);
777
- return `${level ? `#h(${level * 1.25}em)` : ""}${verse}`;
778
- })
779
- .join(" \\\n"),
778
+ stanza[0]?.stanzaBreak ?
779
+ "#v(0.5em)"
780
+ : stanza
781
+ .map(({ children, level }) => {
782
+ const verse = renderInline({ children }, ctx);
783
+ return `${level ? `#h(${level * 1.25}em)` : ""}${verse}`;
784
+ })
785
+ .join(" \\\n"),
780
786
  )
781
787
  .join("\n\n");
782
788
  const anchor = poem?.id ? ` <${sectionLabel(ctx.anchorPrefix, poem.id)}>` : "";
@@ -1291,12 +1297,14 @@ const typstNumber = (number) => (number ? `[#text(fill: luma(45%))[${number}]]`
1291
1297
  function numberedVerse(token, step, ctx) {
1292
1298
  let count = 0;
1293
1299
  const stanzas = token.meta.verseStanzas.map((stanza) =>
1294
- stanza
1295
- .map(({ children, level }) => {
1296
- const verse = renderInline({ children }, ctx);
1297
- return ` ${typstNumber(lineNumber(++count, step))}, [${level ? `#h(${level * 1.25}em)` : ""}${verse}],`;
1298
- })
1299
- .join("\n"),
1300
+ stanza[0]?.stanzaBreak ?
1301
+ " grid.cell(colspan: 2)[#v(0.5em)],"
1302
+ : stanza
1303
+ .map(({ children, level }) => {
1304
+ const verse = renderInline({ children }, ctx);
1305
+ return ` ${typstNumber(lineNumber(++count, step))}, [${level ? `#h(${level * 1.25}em)` : ""}${verse}],`;
1306
+ })
1307
+ .join("\n"),
1300
1308
  );
1301
1309
  return [
1302
1310
  "#grid(columns: (2.5em, 1fr), column-gutter: 1em, row-gutter: 0.35em,",
@@ -1309,44 +1317,52 @@ function numberedVerse(token, step, ctx) {
1309
1317
  * A verse in columns: a grid with a row per verse line, its number first when
1310
1318
  * the verse is numbered, then each column's entries stacked, the first in the
1311
1319
  * normal tone and each later one grey. A part whose text runs right to left
1312
- * is set right to left inside its cell, and the grid keeps the page's
1313
- * direction, so the columns stay in order. A `***` line is a row of space
1320
+ * is set right to left inside its cell; the grid stays left to right so
1321
+ * numbered columns retain their authored order. A `***` line is a row of space
1314
1322
  * across the grid.
1315
1323
  *
1316
- * @param {object} token - The expanded verse's fence token.
1324
+ * @param {object} token - The verse's fence token with parsed logical rows.
1317
1325
  * @param {object} poem - The verse, from `scanVerse`.
1318
- * @param {string} opening - Its opening line.
1319
1326
  * @param {number} step - Its `numstep`.
1320
1327
  * @param {object} ctx - Render context.
1321
1328
  * @returns {string} Typst markup.
1322
1329
  */
1323
- function verseGrid(token, poem, opening, step, ctx) {
1324
- const counts = verseColumns(poem, opening).map((column) => column.entries.length);
1325
- const width = counts.length + (step ? 1 : 0);
1326
- const columns = [...(step ? ["2.5em"] : []), ...counts.map(() => "1fr")];
1327
- const raw = verseStanzas(token.content);
1330
+ function verseGrid(token, poem, step, ctx) {
1331
+ const count = verseColumnCount(poem);
1332
+ const width = count + (step ? 1 : 0);
1333
+ const columns = [...(step ? ["2.5em"] : []), ...Array(count).fill("1fr")];
1328
1334
  const rows = [];
1329
- let count = 0;
1330
- token.meta.verseStanzas.forEach((group, at) => {
1331
- if (raw[at].length === 1 && STANZA_BREAK.test(raw[at][0].text)) {
1335
+ let row = 0;
1336
+ for (const group of token.meta.verseGroups) {
1337
+ if (group.stanzaBreak) {
1332
1338
  rows.push(` grid.cell(colspan: ${width})[#v(0.5em)],`);
1333
- return;
1339
+ continue;
1334
1340
  }
1335
- const parts = group.map(({ children }, k) => {
1336
- const part = renderInline({ children }, ctx);
1337
- return rightToLeft(raw[at][k].text) ? `#set text(dir: rtl)\n${part}` : part;
1338
- });
1339
- let next = 0;
1340
- const cells = counts.map((n) => {
1341
- const stacked = parts.slice(next, (next += n));
1341
+ const cells = Array.from({ length: count }, () => []);
1342
+ for (const part of group.parts) {
1343
+ const rtl = rightToLeft(part.text);
1344
+ const secondary = cells[part.column - 1].length > 0;
1345
+ const text = renderInline(
1346
+ { children: part.children },
1347
+ secondary ? { ...ctx, textFill: "luma(45%)" } : ctx,
1348
+ );
1349
+ const padded =
1350
+ part.indent ?
1351
+ `#pad(${rtl ? "right" : "left"}: ${part.indent * 1.25}em)[${text}]`
1352
+ : text;
1353
+ cells[part.column - 1].push(rtl ? `#set text(dir: rtl)\n${padded}` : padded);
1354
+ }
1355
+ const rendered = cells.map((stacked) => {
1356
+ if (!stacked.length) return "[]";
1342
1357
  if (stacked.length === 1) return `[${stacked[0]}]`;
1343
1358
  return `[#stack(dir: ttb, spacing: 0.3em, ${stacked.map((part, k) => (k ? `[#text(fill: luma(45%))[${part}]]` : `[${part}]`)).join(", ")})]`;
1344
1359
  });
1345
- const number = step ? [typstNumber(lineNumber(++count, step))] : [];
1346
- rows.push(` ${[...number, ...cells].join(", ")},`);
1347
- });
1360
+ const number = step ? [typstNumber(lineNumber(++row, step))] : [];
1361
+ rows.push(` ${[...number, ...rendered].join(", ")},`);
1362
+ }
1348
1363
  return [
1349
- `#grid(columns: (${columns.join(", ")}${columns.length === 1 ? "," : ""}), column-gutter: 1.5em, row-gutter: 0.65em,`,
1364
+ "#set text(dir: ltr)",
1365
+ `#grid(columns: (${columns.join(", ")}), column-gutter: 1.5em, row-gutter: 0.65em,`,
1350
1366
  ...rows,
1351
1367
  ")",
1352
1368
  ].join("\n");
@@ -1440,7 +1456,7 @@ function renderInline(token, ctx) {
1440
1456
  case "script_emphasis_open":
1441
1457
  out.push(
1442
1458
  child.meta?.convention === "rubric" ?
1443
- "#text(fill: book-rubric)["
1459
+ `#text(fill: ${ctx.textFill ?? "book-rubric"})[`
1444
1460
  : "#overline[",
1445
1461
  );
1446
1462
  break;
@@ -1675,6 +1691,9 @@ function pageOfLabel(label) {
1675
1691
  * @returns {string} Typst markup.
1676
1692
  */
1677
1693
  function renderLink(href, inner, ctx, { bracketed = false } = {}) {
1694
+ // A secondary verse reading keeps its label gray inside the global blue
1695
+ // link presentation, including any native spans or emphasis in that label.
1696
+ if (ctx.textFill) inner = `#text(fill: ${ctx.textFill})[${inner}]`;
1678
1697
  const url = String(href ?? "");
1679
1698
  const fragment = /#([^?]*)/.exec(url)?.[1] ?? "";
1680
1699
  if (url.startsWith("#") && fragment)
@@ -17,9 +17,10 @@
17
17
  * column value and none is a key, so two notes writing one tag in different
18
18
  * case are two rows. Documentation records and assets contribute no rows.
19
19
  *
20
- * A run's `font` and `map` are the ones it is set with: its own, else those of
21
- * the div or fence around it, else those its language's note names in
22
- * `data.writtenIn`, filled by {@link fillWrittenIn} once every record exists.
20
+ * Native runs retain their explicit opt-in so language defaults can be filled
21
+ * by {@link fillWrittenIn} once every record exists. Roman language-only runs
22
+ * retain null map/font fields; ordinary generic-family inheritance is already
23
+ * projected by the shared scanner.
23
24
  *
24
25
  * @module
25
26
  */
@@ -30,7 +31,7 @@ import { isNoteRecord } from "./index-records.mjs";
30
31
  import { scanRuns } from "./content-scripts.mjs";
31
32
  import { languageRecordsByName } from "./map-lexicon.mjs";
32
33
  import { mapsFromRecords } from "./text-maps.mjs";
33
- import { fromWrittenIn, writtenInOf } from "./written-in.mjs";
34
+ import { writtenInOf } from "./written-in.mjs";
34
35
 
35
36
  /** Every column of the `runs` relation, in order. */
36
37
  export const RUN_COLUMNS = Object.freeze([
@@ -52,10 +53,12 @@ export const RUN_COLUMNS = Object.freeze([
52
53
  * @property {string|null} lang - The language it is set in, as written: its
53
54
  * own `lang=`, else the one the div or fence around it states.
54
55
  * @property {string|null} tag - The BCP 47 tag it resolves to, or null.
55
- * @property {string|null} font - Its font, as written and inherited the same
56
- * way, else its language's script's.
57
- * @property {string|null} map - Its map, as written and inherited the same way,
58
- * else its language's script's.
56
+ * @property {string|null} font - Its explicit font, an inherited ordinary
57
+ * prose family, or the configured default for an opted-in native run.
58
+ * @property {string|null} map - Its explicit map or the configured map of an
59
+ * opted-in native run; null for Roman language-only text.
60
+ * @property {boolean} [nativefont] - Explicit native opt-in on this construct;
61
+ * absent on Roman runs. This internal flag is not a SQL column.
59
62
  * @property {number} line - 1-based file line of its opening.
60
63
  * @property {number} column - 1-based UTF-16 file column of its opening.
61
64
  */
@@ -84,13 +87,14 @@ export function recordRuns(body, { context, bodyLine = 1, bodyColumn = 1, syntax
84
87
  tag: run.tag ?? null,
85
88
  font: run.font ?? null,
86
89
  map: run.map ?? null,
90
+ ...(run.nativefont ? { nativefont: true } : {}),
87
91
  line: bodyLine + run.line - 1,
88
92
  column: run.column + (run.line === 1 ? bodyColumn - 1 : 0),
89
93
  }));
90
94
  }
91
95
 
92
96
  /**
93
- * Give every recorded run the map and face its language's note names in
97
+ * Give explicitly native recorded runs the map and face their language names in
94
98
  * `data.writtenIn`, where the run leaves them unstated, as a run is set. Read
95
99
  * once every record exists, since a run's language note may be recorded after
96
100
  * the note that marks it.
@@ -107,17 +111,11 @@ export function fillWrittenIn(records, config) {
107
111
  for (const record of records) {
108
112
  if (!record || !isNoteRecord(record) || !Array.isArray(record.runs)) continue;
109
113
  for (const run of record.runs) {
110
- const filled = fromWrittenIn(
111
- {
112
- lang: run.lang ?? undefined,
113
- map: run.map ?? undefined,
114
- font: run.font ?? undefined,
115
- },
116
- scripts,
117
- maps,
118
- );
119
- if (filled.map !== undefined) run.map = filled.map;
120
- if (filled.font !== undefined) run.font = filled.font;
114
+ if (!run.nativefont) continue;
115
+ const script = scripts.get(run.lang);
116
+ if (!script) continue;
117
+ if (run.map == null) run.map = script.map;
118
+ if (run.font == null) run.font = script.font;
121
119
  }
122
120
  }
123
121
  }
@@ -76,14 +76,15 @@ const FRONTMATTER = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
76
76
  *
77
77
  * @param {string} source - The note's file.
78
78
  * @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
79
+ * @param {import("./content-session.mjs").ContentNoteSource} [parsed] - Already parsed authored note.
79
80
  * @returns {string|undefined} The face's slug.
80
81
  */
81
- function writtenInFace(source, installed) {
82
+ function writtenInFace(source, installed, parsed) {
82
83
  const yaml = FRONTMATTER.exec(source)?.[1];
83
84
  if (yaml === undefined) return undefined;
84
85
  let fm;
85
86
  try {
86
- fm = YAML.parse(yaml);
87
+ fm = parsed ? parsed.frontmatter : YAML.parse(yaml);
87
88
  } catch {
88
89
  return undefined;
89
90
  }
@@ -102,10 +103,12 @@ function writtenInFace(source, installed) {
102
103
  *
103
104
  * @param {DeliveryConfig} config - The resolved configuration.
104
105
  * @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
106
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources when staging within a content build.
105
107
  * @returns {ReadonlySet<string>} The slugs of the faces used.
106
108
  */
107
- export function facesUsed(config, installed) {
108
- const known = usedFaces.get(config);
109
+ export function facesUsed(config, installed, session) {
110
+ const cacheKey = session ?? config;
111
+ const known = usedFaces.get(cacheKey);
109
112
  if (known) return known;
110
113
  const used = new Set();
111
114
  const content =
@@ -119,9 +122,9 @@ export function facesUsed(config, installed) {
119
122
  const full = path.join(dir, entry.name);
120
123
  if (entry.isDirectory()) walk(full);
121
124
  else if (entry.isFile() && entry.name.endsWith(".md")) {
122
- const source = fs.readFileSync(full, "utf8");
125
+ const source = session?.sources.get(full)?.raw ?? fs.readFileSync(full, "utf8");
123
126
  if (/^\s*writtenIn:/m.test(source)) {
124
- const face = writtenInFace(source, installed);
127
+ const face = writtenInFace(source, installed, session?.parsed.get(full));
125
128
  if (face) used.add(face);
126
129
  }
127
130
  if (!/\bfont=/.test(source)) continue;
@@ -135,7 +138,7 @@ export function facesUsed(config, installed) {
135
138
  }
136
139
  };
137
140
  if (content && fs.existsSync(content)) walk(content);
138
- usedFaces.set(config, used);
141
+ usedFaces.set(cacheKey, used);
139
142
  return used;
140
143
  }
141
144
 
@@ -143,15 +146,16 @@ export function facesUsed(config, installed) {
143
146
  * The installed package, when this consumer is to ship it.
144
147
  *
145
148
  * @param {DeliveryConfig} config - The resolved configuration.
149
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources.
146
150
  * @returns {import("./script-fonts.mjs").ScriptFonts|undefined} The package, or
147
151
  * `undefined` without a root, an install, a manifest, or a note that sets
148
152
  * text in one of its faces.
149
153
  */
150
- function delivered(config) {
154
+ function delivered(config, session) {
151
155
  if (!config?.rootDir) return undefined;
152
156
  const installed = resolveScriptFonts(config.rootDir);
153
157
  if (!installed?.manifest) return undefined;
154
- return facesUsed(config, installed).size ? installed : undefined;
158
+ return facesUsed(config, installed, session).size ? installed : undefined;
155
159
  }
156
160
 
157
161
  /**
@@ -159,12 +163,13 @@ function delivered(config) {
159
163
  *
160
164
  * @param {DeliveryConfig} config - The resolved configuration.
161
165
  * @param {string[]} formats - The font formats the target uses: `woff2`, `ttf`.
166
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources when staging within a content build.
162
167
  * @returns {{root: string, version: string, files: string[]}|undefined} The
163
168
  * files, sorted: `fonts.css`, `LICENSE` and every file in `fonts/` of a
164
169
  * listed format. `undefined` when the consumer ships none.
165
170
  */
166
- export function scriptFontFiles(config, formats) {
167
- const installed = delivered(config);
171
+ export function scriptFontFiles(config, formats, session) {
172
+ const installed = delivered(config, session);
168
173
  if (!installed) return undefined;
169
174
  const fontsDir = path.join(installed.root, "fonts");
170
175
  const fonts =
@@ -250,10 +255,11 @@ export function stagedStylesheet(css, staged) {
250
255
  * @param {string} dest - The directory to fill; created when there is
251
256
  * something to put in it.
252
257
  * @param {string[]} formats - The font formats the target uses.
258
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources when staging within a content build.
253
259
  * @returns {number} How many files were written; 0 when the consumer ships none.
254
260
  */
255
- export function copyScriptFonts(config, dest, formats) {
256
- const found = scriptFontFiles(config, formats);
261
+ export function copyScriptFonts(config, dest, formats, session) {
262
+ const found = scriptFontFiles(config, formats, session);
257
263
  if (!found) return 0;
258
264
  const staged = new Set(found.files);
259
265
  for (const file of found.files) {
@@ -274,11 +280,12 @@ export function copyScriptFonts(config, dest, formats) {
274
280
  * stylesheet, the licence and every file the manifest lists.
275
281
  *
276
282
  * @param {DeliveryConfig} config - The resolved configuration.
283
+ * @param {import("./content-session.mjs").ContentSession} [session] - Captured sources when staging within a content build.
277
284
  * @returns {{file: string, severity: "error", message: string}[]} One finding
278
285
  * per missing file, located at the manifest; empty when the consumer ships none.
279
286
  */
280
- export function checkScriptFonts(config) {
281
- const installed = delivered(config);
287
+ export function checkScriptFonts(config, session) {
288
+ const installed = delivered(config, session);
282
289
  if (!installed) return [];
283
290
  const manifest = path.join(installed.root, FONTS_MANIFEST);
284
291
  const wanted = new Set([STYLESHEET, LICENSE]);
@@ -58,6 +58,13 @@ import { parseWordClass } from "./word-classes.mjs";
58
58
  import { derivedForms, foldWord } from "./word-forms.mjs";
59
59
  import { WORD } from "./word-pattern.mjs";
60
60
 
61
+ import MarkdownIt from "markdown-it";
62
+ import { spanMarkdownPlugin } from "./content-spans.mjs";
63
+ import { STANZA_BREAK, verseLine } from "./content-verse.mjs";
64
+ import { inlineText } from "./name-markup.mjs";
65
+
66
+ const verseParser = new MarkdownIt().use(spanMarkdownPlugin);
67
+
61
68
  export { METRE_KINDS, METRE_NAME, checkMetresField } from "./metre-declaration.mjs";
62
69
 
63
70
  /** The bases whose one-syllable words carry a beat. */
@@ -285,7 +292,7 @@ export function metreMessage({ min, max }, metre) {
285
292
  * @param {ReadonlyArray<{start: number, end: number, indent: string, attributes: Record<string, string>, classes: string[]}>} blocks -
286
293
  * The fences, from {@link module:engine/content-verse.scanVerse}.
287
294
  * @param {string[]} lines - The source's lines.
288
- * @param {{metres?: ReadonlyMap<string, import("./metre-declaration.mjs").Metre>, metreBooks?: ReadonlyMap<string, MetreBook>, lexicons?: ReadonlyMap<string, unknown>, columnsClass: string, columnKeys: readonly string[], attributeColumns: (line: string, key: string) => {key: number, value: number}}} ctx -
295
+ * @param {{languages?: Readonly<Record<string, {tag: string}>>, metres?: ReadonlyMap<string, import("./metre-declaration.mjs").Metre>, metreBooks?: ReadonlyMap<string, MetreBook>, lexicons?: ReadonlyMap<string, unknown>, attributeColumns: (line: string, key: string) => {key: number, value: number}}} ctx -
289
296
  * The metres and books, and how the fence says its columns and where its
290
297
  * keys sit.
291
298
  * @returns {{errors: object[], warnings: object[]}} The findings at 1-based
@@ -304,13 +311,10 @@ export function metreFindings(blocks, lines, ctx) {
304
311
  column: ctx.attributeColumns(opening, key)[part],
305
312
  });
306
313
  const error = (position, message) => errors.push({ ...position, message });
307
- if (
308
- block.classes.includes(ctx.columnsClass) ||
309
- ctx.columnKeys.some((key) => block.attributes[key] !== undefined)
310
- ) {
314
+ if (Number(block.attributes.cols ?? 1) > 1) {
311
315
  error(
312
316
  at("scan", "key"),
313
- "`scan=` counts the beats of one language's lines; a verse in columns sets a line in each of two",
317
+ "`scan=` counts the beats of one language's lines; a verse with cols>1 groups independent physical lines",
314
318
  );
315
319
  continue;
316
320
  }
@@ -344,8 +348,32 @@ export function metreFindings(blocks, lines, ctx) {
344
348
  }
345
349
  for (let line = block.start + 1; line < block.end; line++) {
346
350
  const text = lines[line];
347
- if (!text.trim()) continue;
348
- const beats = lineBeats(text, book);
351
+ if (!text.trim() || STANZA_BREAK.test(text.trim())) continue;
352
+ const reading = verseLine(text);
353
+ const tokens = verseParser.parseInline(reading.text, {})[0]?.children ?? [];
354
+ const tag = (name) => (ctx.languages?.[name]?.tag ?? name).toLowerCase();
355
+ const foreign = tokens.find(
356
+ (token) =>
357
+ token.type === "span_open" &&
358
+ token.attrGet("lang") &&
359
+ tag(token.attrGet("lang")) !== tag(lang),
360
+ );
361
+ if (foreign) {
362
+ error(
363
+ {
364
+ line: line + 1,
365
+ column:
366
+ reading.textColumn +
367
+ foreign.meta.start +
368
+ (reading.markerStart <= reading.textColumn - 1 + foreign.meta.start ?
369
+ reading.markerLength
370
+ : 0),
371
+ },
372
+ "`scan=` counts one language's beats; this verse line contains another language",
373
+ );
374
+ continue;
375
+ }
376
+ const beats = lineBeats(inlineText(tokens), book);
349
377
  if (metre.beats >= beats.min && metre.beats <= beats.max) continue;
350
378
  warnings.push({
351
379
  line: line + 1,
@@ -20,20 +20,16 @@
20
20
  * writtenIn: { map: semaron, font: semaron-kuriale }
21
21
  * ```
22
22
  *
23
- * A run marked with the language's name takes them where it states neither:
24
- * `[Zaron tholmeza]{lang=vyl}` is mapped by `semaron` and set in
25
- * `semaron-kuriale`. A run that states one of the two takes the other from the
26
- * default only where they agree, so the run is never set in a face its map is
27
- * not drawn in:
23
+ * These are native-presentation defaults, not an effect of identifying a
24
+ * language. A rendering run requests them with its own `.nativefont` class
25
+ * and explicit `lang=`; plain `[Zaron tholmeza]{lang=vyl}` stays Roman.
26
+ * The renderer fills the map and font independently, honoring explicit
27
+ * overrides and reporting an incompatible pair rather than silently omitting
28
+ * a default. A nested marked run makes its own presentation choice.
28
29
  *
29
- * - **`map=` alone** takes the default `font` where that map is drawn in it.
30
- * - **`font=` alone** takes the default `map` where the map is drawn in that
31
- * face.
32
- * - **Both** take nothing.
33
- *
34
- * The default belongs to the run's own language, after a div or fence around
35
- * it has given it its keys: a span marked `lang=en` inside a div of a language
36
- * with a default takes no face from it.
30
+ * The low-level `fromWrittenIn` helper also serves explicit conversion
31
+ * commands. It returns compatible default suggestions to those callers;
32
+ * rendering opt-in and its required-font diagnostics belong to the run reader.
37
33
  *
38
34
  * @module
39
35
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "24.0.4",
3
+ "version": "24.1.1",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -1384,7 +1384,7 @@ a.tag:hover {
1384
1384
  background: var(--bg-card);
1385
1385
  }
1386
1386
 
1387
- /* Verse lines are hard breaks in the markup; paragraphs mark stanzas. */
1387
+ /* Verse groups are paragraphs; explicit stanza separators add their own gap. */
1388
1388
  .single-content .verse {
1389
1389
  margin: 1.5rem 0;
1390
1390
  line-height: 1.7;
@@ -1404,14 +1404,16 @@ a.tag:hover {
1404
1404
  white-space: pre-wrap;
1405
1405
  }
1406
1406
 
1407
- .single-content .verse .verse-line.i1 { --verse-indent: 1.25em; }
1408
- .single-content .verse .verse-line.i2 { --verse-indent: 2.5em; }
1409
- .single-content .verse .verse-line.i3 { --verse-indent: 3.75em; }
1410
- .single-content .verse .verse-line.i4 { --verse-indent: 5em; }
1411
- .single-content .verse .verse-line.i5 { --verse-indent: 6.25em; }
1412
- .single-content .verse .verse-line.i6 { --verse-indent: 7.5em; }
1413
- .single-content .verse .verse-line.i7 { --verse-indent: 8.75em; }
1414
- .single-content .verse .verse-line.i8 { --verse-indent: 10em; }
1407
+ .single-content .verse :is(.verse-line, .verse-part).i1 { --verse-indent: 1.25em; }
1408
+ .single-content .verse :is(.verse-line, .verse-part).i2 { --verse-indent: 2.5em; }
1409
+ .single-content .verse :is(.verse-line, .verse-part).i3 { --verse-indent: 3.75em; }
1410
+ .single-content .verse :is(.verse-line, .verse-part).i4 { --verse-indent: 5em; }
1411
+ .single-content .verse :is(.verse-line, .verse-part).i5 { --verse-indent: 6.25em; }
1412
+ .single-content .verse :is(.verse-line, .verse-part).i6 { --verse-indent: 7.5em; }
1413
+ .single-content .verse :is(.verse-line, .verse-part).i7 { --verse-indent: 8.75em; }
1414
+ .single-content .verse :is(.verse-line, .verse-part).i8 { --verse-indent: 10em; }
1415
+
1416
+ .single-content .verse .verse-stanza-break { display: block; height: 0.5em; }
1415
1417
 
1416
1418
  /* Numbered verse carries its numbers in a margin. A verse in columns is one
1417
1419
  grid per verse line: its number, then each column's parts stacked, the
@@ -1446,12 +1448,14 @@ a.tag:hover {
1446
1448
  display: grid;
1447
1449
  grid-auto-flow: column;
1448
1450
  grid-auto-columns: 1fr;
1451
+ direction: ltr;
1452
+ grid-template-columns: repeat(var(--verse-columns, 2), minmax(0, 1fr));
1449
1453
  column-gap: 1.5rem;
1450
1454
  margin-bottom: 0.5em;
1451
1455
  }
1452
1456
 
1453
1457
  .single-content .verse-numbered.verse-columns .verse-row {
1454
- grid-template-columns: 2.5em;
1458
+ grid-template-columns: 2.5em repeat(var(--verse-columns, 2), minmax(0, 1fr));
1455
1459
  }
1456
1460
 
1457
1461
  .single-content .verse-columns .verse-number {
@@ -1466,12 +1470,17 @@ a.tag:hover {
1466
1470
 
1467
1471
  .single-content .verse-columns .verse-part {
1468
1472
  font-family: var(--font-body);
1473
+ padding-inline-start: var(--verse-indent, 0em);
1469
1474
  }
1470
1475
 
1471
1476
  .single-content .verse-columns .verse-secondary {
1472
1477
  color: var(--text-muted);
1473
1478
  }
1474
1479
 
1480
+ .single-content .verse-columns .verse-secondary * {
1481
+ color: inherit;
1482
+ }
1483
+
1475
1484
  .single-content .verse-columns .verse-part[dir="rtl"] {
1476
1485
  text-align: start;
1477
1486
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The repository's `packageBuild.assetTransform`, loaded.
3
+ *
4
+ * One loader for every command that writes a picture, so the stage and the CDN
5
+ * hold the same bytes for the same file.
6
+ *
7
+ * @param {object} config - The resolved package-build configuration.
8
+ * @returns {Promise<((sourcePath: string) => string|null|undefined)|undefined>}
9
+ * The transform, or `undefined` when the repository names none.
10
+ */
11
+ export function loadAssetTransform(config: object): Promise<((sourcePath: string) => string | null | undefined) | undefined>;
12
+ /**
13
+ * Stage static files, provenance-filtered pictures, base styles and fonts.
14
+ * @param {object} [options]
15
+ * @param {ReturnType<typeof loadPackageBuildConfig>} [options.config]
16
+ * @param {import("../content-config.mjs").ContentBuildConfig} [options.packConfig]
17
+ * @param {import("./content-session.mjs").ContentSession} [options.session]
18
+ */
19
+ export function stagePackageBuildAssets({ config, packConfig, session, }?: {
20
+ config?: Readonly<import("../config.mjs").PackageBuildConfig> | undefined;
21
+ packConfig?: import("../content-config.mjs").ContentBuildConfig | undefined;
22
+ session?: import("./content-session.mjs").ContentSession | undefined;
23
+ }): Promise<void>;
@@ -3,11 +3,15 @@
3
3
  *
4
4
  * @param {object} opts
5
5
  * @param {object} opts.config - Resolved content configuration.
6
+ * @param {Map<string, {raw: string}>} [opts.sources] - Captured authored text for diagnostics.
6
7
  * @param {string} opts.calendariaVersion - Version whose import format is targeted.
7
8
  * @returns {{directory: string, calendars: number, files: number}}
8
9
  */
9
- export function emitCalendarArtifacts({ config, calendariaVersion }?: {
10
+ export function emitCalendarArtifacts({ config, calendariaVersion, sources }?: {
10
11
  config: object;
12
+ sources?: Map<string, {
13
+ raw: string;
14
+ }> | undefined;
11
15
  calendariaVersion: string;
12
16
  }): {
13
17
  directory: string;