@heroiclands/package-build 25.5.2 → 25.5.3

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 25.5.3
4
+
5
+ **Verse**
6
+
7
+ - Every `--- {.canto}` break draws a short centred rule on the site, in Foundry journals and in the book, whether or not it carries a title.
8
+ - Decoded verse ends each canto mark with a line break, so the decoded text can be encoded again without the next word running into the mark.
9
+ - Metre checks count only the verse itself, skipping speaker labels and stage directions.
10
+ - Metre checks read a word's inherited word class, and accept every beat count a word with several senses can take.
11
+ - A stanza ending in a footnote reference keeps its stanza mark in the native script, and the reference stays on the roman reading.
12
+ - A line holding only spaces ends a verse stanza, just as an empty line does.
13
+
3
14
  ## 25.5.2
4
15
 
5
16
  **Languages and scripts**
@@ -239,7 +239,9 @@
239
239
  .verse
240
240
  .verse-canto-break {
241
241
  display: block;
242
- height: 0.5em;
242
+ width: 25%;
243
+ margin: 1.5em auto;
244
+ border-block-start: 1px solid currentColor;
243
245
  }
244
246
  :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
245
247
  .verse-paired
@@ -411,7 +411,7 @@ Foundry renders a secret section; the website renders an expandable spoiler, and
411
411
 
412
412
  A `.verse` div sets a version of a work, whether poetry or prose. `/` ends a
413
413
  logical line. Source newlines otherwise join with whitespace, so prose can wrap
414
- across source lines. A blank line or `//` ends a stanza (a paragraph); a line
414
+ across source lines. A blank line, including one containing only spaces, or `//` ends a stanza (a paragraph); a line
415
415
  of `--- {.canto}`, preceded by a blank line, ends a canto (a section). The last line before either
416
416
  boundary needs no `/`. A literal slash in text is written `\/`; slashes inside
417
417
  Markdown syntax, such as a link destination, remain syntax.
@@ -488,6 +488,9 @@ language's stress rules; it reports an error at the line:
488
488
 
489
489
  > `` `scan=` counts one language’s beats; this verse line contains another language ``
490
490
 
491
+ Speaker labels and stage directions (`.speaker` and `.direction`) are presentation
492
+ only: their text is not scanned, and a line containing only those spans is skipped.
493
+
491
494
  Beats are counted this way:
492
495
 
493
496
  - **Syllables** are vowel groups. A vowel is a letter whose base is `a`, `e`,
@@ -500,8 +503,10 @@ Beats are counted this way:
500
503
  - **A one-syllable noun, verb, adjective, numeral or name has a beat.** A
501
504
  one-syllable particle, preposition, conjunction, adverb or pronoun has none.
502
505
  The `class` of the lexicon entry decides, so the language's lexicon is read
503
- through `lang=` as it is for the word check, and a form the language's rules
504
- derive takes its word's class. A word whose entries differ in class is a
506
+ through `lang=` as it is for the word check, including words inherited from
507
+ its family. All entries for a spelling in the nearest lexicon supply their
508
+ classes. A form the
509
+ language's own rules derive takes its word's class. A word whose entries differ in class is a
505
510
  beat that may be left out.
506
511
  - **A one-syllable word the lexicon does not hold is a content word** and has a
507
512
  beat. The word check reports the word separately.
@@ -649,6 +654,8 @@ definitions.
649
654
  #### Canto headings
650
655
 
651
656
  A canto break stands alone after a blank line, with a stanza on both sides.
657
+ It draws a short centred rule on the site, in Foundry journals and in the book,
658
+ with extra space above and below to distinguish it from a stanza break.
652
659
  Its optional `title` appears as a heading and its optional `#id` is an anchor
653
660
  within the work, without starting a separate Foundry journal page:
654
661
 
@@ -542,8 +542,11 @@ The title is shown once as heading text; the encoder receives its Roman `***`
542
542
  canto control. Authored `***` inside verse is a finding. The canto stays within
543
543
  the work and does not open a separate journal page.
544
544
 
545
- The blank line before a canto retains its stanza mark in the encoded text.
546
- Whitespace after the canto control adds no extra line mark.
545
+ The blank line before a canto retains its stanza mark in the encoded text,
546
+ including when the last word has a footnote reference.
547
+ Whitespace after the canto control adds no extra line mark. Decoding writes
548
+ `***` followed by an LF newline, so the next word starts on a new line and
549
+ the decoded Roman text can be encoded again.
547
550
 
548
551
  ### Versions of a work
549
552
 
@@ -336,7 +336,7 @@ Caption classes are unrestricted. The built-in `border` class draws the block as
336
336
  A `.verse` div reaches the page as `.verse`, with one `.verse-stanza` per
337
337
  stanza and `.verse-line` per logical line. Indentation uses `i1` through `i8`;
338
338
  deeper levels carry their complete `--verse-indent`. `.verse-canto-break`
339
- separates cantos. A work-labelled div adds `.verse-labelled`, and each stanza
339
+ draws a short centred rule between cantos. A work-labelled div adds `.verse-labelled`, and each stanza
340
340
  opens with a small `.verse-label` shared across its presentations.
341
341
 
342
342
  A concrete div encoding adds `.verse-paired`. Each `.verse-row` aligns one
@@ -2595,7 +2595,13 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
2595
2595
  });
2596
2596
  if (positions.length)
2597
2597
  patches.push({ from: positions[0], to: positions.at(-1) + 1, text: edit.text });
2598
- else if (edit.from === edit.to && edit.from === base + line.to)
2598
+ else if (
2599
+ (edit.from === edit.to && edit.from === base + line.to) ||
2600
+ (edit.from > base + (line.offsets.at(-1) ?? line.from) &&
2601
+ edit.to <= base + line.to)
2602
+ )
2603
+ // Trailing source whitespace has no logical-text offsets.
2604
+ // It may still carry a stanza control after a footnote hole.
2599
2605
  patches.push({ from: line.text.length, to: line.text.length, text: edit.text });
2600
2606
  }
2601
2607
  let result = line.text;
@@ -569,7 +569,8 @@ export function parseVerse(body, { inline = false, footnotes = new Set() } = {})
569
569
  const length = source[at] === "\r" && source[at + 1] === "\n" ? 2 : 1;
570
570
  if (text && !/\s$/.test(text)) append(" ", at);
571
571
  at += length;
572
- while (source[at] === " ") at++;
572
+ // Leave blank physical lines at their start so they end the stanza.
573
+ if (!/^[ \t]*(?:\r\n|\r|\n|$)/.test(source.slice(at))) while (source[at] === " ") at++;
573
574
  continue;
574
575
  }
575
576
  if (!text && source[at] === " ") {
@@ -229,8 +229,9 @@ function languageNotes(languages, contentPackage, records) {
229
229
 
230
230
  /**
231
231
  * Each configured language's lexicon, read from the language note its `note`
232
- * names and its family chain. The nearest exact NFC form supplies the whole
233
- * word; grammar, registers and other settings remain the daughter's own.
232
+ * names and its family chain. All entries for an exact NFC form come from
233
+ * the nearest lexicon declaring it; grammar, registers and other settings
234
+ * remain the daughter's own.
234
235
  * A language with no `note`, or one naming no language note, has none.
235
236
  *
236
237
  * @param {Iterable<Record<string, any>>|undefined} records - Index records.
@@ -259,8 +260,10 @@ export function lexiconsFromRecords(records, config = {}) {
259
260
  const chain = lineageChain(key, lineage);
260
261
  let data = resolvedNativeData(record.data, record.package ?? config.contentPackage);
261
262
  if (chain.length > 1) {
262
- const words = new Map();
263
- for (const ancestor of chain)
263
+ const words = [];
264
+ const inherited = new Set();
265
+ for (const ancestor of chain) {
266
+ const forms = new Set();
264
267
  for (const word of listOf(
265
268
  resolvedNativeData(
266
269
  byKey.get(ancestor).data,
@@ -268,12 +271,15 @@ export function lexiconsFromRecords(records, config = {}) {
268
271
  )?.words,
269
272
  )) {
270
273
  if (!isMap(word) || typeof word.form !== "string") continue;
271
- // The nearest complete definition wins before word matching
272
- // folds case or splits compounds. Only words are inherited.
273
274
  const form = word.form.normalize("NFC");
274
- if (!words.has(form)) words.set(form, word);
275
+ // Retain every sense in the nearest generation, in authored
276
+ // order, before hiding that form in more distant ancestors.
277
+ if (!inherited.has(form)) words.push(word);
278
+ forms.add(form);
275
279
  }
276
- data = { ...data, words: [...words.values()] };
280
+ for (const form of forms) inherited.add(form);
281
+ }
282
+ data = { ...data, words };
277
283
  }
278
284
  lexicons.set(name, lexiconOf(name, data));
279
285
  }
@@ -256,7 +256,7 @@ export function compileRows(definition, metadata = {}) {
256
256
  const prefix = d.separator ? [separatorRow] : [];
257
257
  const controlRows = [];
258
258
  if (controls.verseStanza) controlRows.push({ from: "\n\n", to: controls.verseStanza });
259
- if (controls.verseCanto) controlRows.push({ from: "***", to: controls.verseCanto });
259
+ if (controls.verseCanto) controlRows.push({ from: "***\n", to: controls.verseCanto });
260
260
  const shared = tier(sharedRows, [], [...prefix, ...controlRows]);
261
261
  const faces = new Map();
262
262
  if (d.faces !== undefined && !object(d.faces))
@@ -1297,7 +1297,9 @@ function workVerse(token, poem, ctx) {
1297
1297
  `#heading(level: 3, outlined: false, bookmarked: false)[${renderInline({ children: group.titleChildren }, ctx)}]`
1298
1298
  : "";
1299
1299
  const anchor = group.id ? ` <${sectionLabel(ctx.anchorPrefix, group.id)}>` : "";
1300
- return `${mark ? scriptWrapped(mark, nativeClasses, nativeAttributes) + "\n" : ""}#v(0.5em)${heading ? "\n" + heading : ""}${anchor}`;
1300
+ const rule =
1301
+ "#v(0.75em)\n#align(center)[#line(length: 25%, stroke: 0.4pt)]\n#v(0.75em)";
1302
+ return `${mark ? scriptWrapped(mark, nativeClasses, nativeAttributes) + "\n" : ""}${rule}${heading ? "\n" + heading : ""}${anchor}`;
1301
1303
  }
1302
1304
  const label = labelled ? `#text(size: 0.75em, fill: luma(45%))[${group.label}]` : "";
1303
1305
  const lineText = (line, roman = false) => {
@@ -52,7 +52,7 @@
52
52
  import { nearest } from "./near-miss.mjs";
53
53
  import { isNoteRecord } from "./index-records.mjs";
54
54
  import { METRE_KINDS, METRE_NAME } from "./metre-declaration.mjs";
55
- import { languageRecordsByName } from "./encoding-lexicon.mjs";
55
+ import { lexiconsFromRecords } from "./encoding-lexicon.mjs";
56
56
  import { STRESS_LONGER, STRESS_MARKS, STRESS_TWO } from "./language-terms.mjs";
57
57
  import { parseWordClass } from "./word-classes.mjs";
58
58
  import { derivedForms, foldWord } from "./word-forms.mjs";
@@ -169,8 +169,8 @@ const isRule = (stress) =>
169
169
  */
170
170
  export function metreBooksFromRecords(records, config = {}) {
171
171
  const books = new Map();
172
- for (const [language, record] of languageRecordsByName([...(records ?? [])], config)) {
173
- const data = isMap(record.data) ? record.data : {};
172
+ for (const [language, lexicon] of lexiconsFromRecords(records, config)) {
173
+ const data = lexicon.data;
174
174
  const sounds = isMap(data.sounds) ? data.sounds : {};
175
175
  if (!isRule(sounds.stress)) continue;
176
176
  const syllables = isMap(sounds.syllables) ? sounds.syllables : {};
@@ -358,9 +358,29 @@ export function metreFindings(blocks, lines, ctx) {
358
358
  ({ offset }) =>
359
359
  !model.layout.some(([from, to]) => offset >= from && offset < to),
360
360
  );
361
- const tokens =
361
+ const parsed =
362
362
  verseParser.parseInline(units.map((unit) => unit.text).join(""), {})[0]
363
363
  ?.children ?? [];
364
+ let presentationDepth = 0;
365
+ const tokens = parsed.filter((token) => {
366
+ if (presentationDepth) {
367
+ presentationDepth += token.nesting;
368
+ return false;
369
+ }
370
+ if (
371
+ token.type === "span_open" &&
372
+ token
373
+ .attrGet("class")
374
+ ?.split(/\s+/)
375
+ .some((name) => name === "speaker" || name === "direction")
376
+ ) {
377
+ presentationDepth = 1;
378
+ return false;
379
+ }
380
+ return true;
381
+ });
382
+ const text = inlineText(tokens);
383
+ if (!text.trim()) continue;
364
384
  const tag = (name) => (ctx.languages?.[name]?.tag ?? name).toLowerCase();
365
385
  const foreign = tokens.find(
366
386
  (token) =>
@@ -375,7 +395,7 @@ export function metreFindings(blocks, lines, ctx) {
375
395
  );
376
396
  continue;
377
397
  }
378
- const beats = lineBeats(inlineText(tokens), book);
398
+ const beats = lineBeats(text, book);
379
399
  if (metre.beats >= beats.min && metre.beats <= beats.max) continue;
380
400
  warnings.push({
381
401
  ...sourcePosition(reading.from),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "25.5.2",
3
+ "version": "25.5.3",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -1444,7 +1444,9 @@ a.tag:hover {
1444
1444
  .single-content .verse .verse-line.i8 { --verse-indent: 10em; }
1445
1445
  .single-content .verse .verse-canto-break {
1446
1446
  display: block;
1447
- height: 0.5em;
1447
+ width: 25%;
1448
+ margin: 1.5em auto;
1449
+ border-block-start: 1px solid currentColor;
1448
1450
  }
1449
1451
  .single-content .verse-paired .verse-row {
1450
1452
  display: grid;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Each configured language's lexicon, read from the language note its `note`
3
- * names and its family chain. The nearest exact NFC form supplies the whole
4
- * word; grammar, registers and other settings remain the daughter's own.
3
+ * names and its family chain. All entries for an exact NFC form come from
4
+ * the nearest lexicon declaring it; grammar, registers and other settings
5
+ * remain the daughter's own.
5
6
  * A language with no `note`, or one naming no language note, has none.
6
7
  *
7
8
  * @param {Iterable<Record<string, any>>|undefined} records - Index records.