@heroiclands/package-build 25.5.1 → 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 +17 -0
- package/assets/styles/content-base.css +3 -1
- package/docs/api.md +8 -6
- package/docs/authoring/links-and-markup.md +10 -3
- package/docs/guides/writing-in-invented-scripts.md +5 -2
- package/docs/reference/theme-pages.md +1 -1
- package/engine/content-scripts.mjs +154 -37
- package/engine/content-verse.mjs +2 -1
- package/engine/encoding-lexicon.mjs +14 -8
- package/engine/encoding.mjs +1 -1
- package/engine/pdf-render.mjs +3 -1
- package/engine/roman-characters.mjs +45 -8
- package/engine/verse-metre.mjs +25 -5
- package/package.json +1 -1
- package/site-theme/static/css/style.css +3 -1
- package/types/engine/encoding-lexicon.d.mts +3 -2
- package/types/engine/roman-characters.d.mts +60 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
14
|
+
## 25.5.2
|
|
15
|
+
|
|
16
|
+
**Languages and scripts**
|
|
17
|
+
|
|
18
|
+
- A sentence that runs across several spans and links in one language is checked as one sentence, so the period that ends it is not reported as opening the next.
|
|
19
|
+
|
|
3
20
|
## 25.5.1
|
|
4
21
|
|
|
5
22
|
**Builds and commands**
|
|
@@ -239,7 +239,9 @@
|
|
|
239
239
|
.verse
|
|
240
240
|
.verse-canto-break {
|
|
241
241
|
display: block;
|
|
242
|
-
|
|
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
|
package/docs/api.md
CHANGED
|
@@ -1449,12 +1449,14 @@ The charset authored content is held to, so a book can choose its face. The pack
|
|
|
1449
1449
|
Exact permitted Roman source characters for configured languages, before script
|
|
1450
1450
|
encoding. Inventories come from `data.roman` on each language's own note.
|
|
1451
1451
|
|
|
1452
|
-
| Export
|
|
1453
|
-
|
|
|
1454
|
-
| `isRomanWhitespace`
|
|
1455
|
-
| `romanInventoryProblems`
|
|
1456
|
-
| `romanInventoriesOf`
|
|
1457
|
-
| `romanTextProblems`
|
|
1452
|
+
| Export | Signature | Returns | Use it when |
|
|
1453
|
+
| ------------------------- | -------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1454
|
+
| `isRomanWhitespace` | `isRomanWhitespace(char)` | `boolean` | recognizing standard spaces and ASCII tab or line whitespace |
|
|
1455
|
+
| `romanInventoryProblems` | `romanInventoryProblems(value)` | `string[]` | checking NFC, repeated characters and forbidden whitespace or controls in a literal inventory |
|
|
1456
|
+
| `romanInventoriesOf` | `romanInventoriesOf(records, languages)` | `Map<string, RomanInventory>` | looking up a configured language's own inventory by name or BCP 47 tag |
|
|
1457
|
+
| `romanTextProblems` | `romanTextProblems(text, inventory, stream)` | `{offset, message}[]` | validating standard Roman punctuation and language additions with exact UTF-16 offsets; pass a `stream` state to continue sentences and quotations from earlier runs |
|
|
1458
|
+
| `romanPunctuationState` | `romanPunctuationState()` | `{sentenceStart, quotes}` | starting a stream of runs whose sentences and quotations continue from one run to the next |
|
|
1459
|
+
| `romanUnclosedQuotations` | `romanUnclosedQuotations(quotes)` | `{offset, message}[]` | reporting the quotations still open where a stream of runs ends |
|
|
1458
1460
|
|
|
1459
1461
|
### `engine.pathCharset`
|
|
1460
1462
|
|
|
@@ -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,
|
|
504
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -63,7 +63,12 @@ import { unknownWordMessage, unknownWords, wordBooksFromRecords } from "./run-wo
|
|
|
63
63
|
import { metreBooksFromRecords, metreFindings, metresFromRecords } from "./verse-metre.mjs";
|
|
64
64
|
import { encodeWithLanguage } from "./encoding-language.mjs";
|
|
65
65
|
import { encodingsFromRecords, encodingAddress } from "./encoding-notes.mjs";
|
|
66
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
romanInventoriesOf,
|
|
68
|
+
romanPunctuationState,
|
|
69
|
+
romanTextProblems,
|
|
70
|
+
romanUnclosedQuotations,
|
|
71
|
+
} from "./roman-characters.mjs";
|
|
67
72
|
import { plainRomanRun } from "./roman-run.mjs";
|
|
68
73
|
import { plainRomanVerse } from "./roman-verse.mjs";
|
|
69
74
|
import { encodeText } from "./encoding.mjs";
|
|
@@ -1175,6 +1180,7 @@ function analyseRuns(source, ctx, syntax) {
|
|
|
1175
1180
|
rawReferences.push({
|
|
1176
1181
|
lang: keys.lang,
|
|
1177
1182
|
font: keys.font,
|
|
1183
|
+
enc: keys.enc,
|
|
1178
1184
|
from: match.index + 2 + bar + 1,
|
|
1179
1185
|
to: match.index + close - 1,
|
|
1180
1186
|
where: construct.where,
|
|
@@ -1363,24 +1369,6 @@ function analyseRuns(source, ctx, syntax) {
|
|
|
1363
1369
|
(ctx.romanInventories?.get(lang) ?? ctx.romanInventories?.get(lang.toLowerCase()))
|
|
1364
1370
|
);
|
|
1365
1371
|
|
|
1366
|
-
/** Check visible source before selection or lexicon-native replacement. */
|
|
1367
|
-
const roman = (parts, detail) => {
|
|
1368
|
-
const inventory = inventoryOf(detail.run.lang);
|
|
1369
|
-
if (!inventory) return;
|
|
1370
|
-
if (!inventory.characters) {
|
|
1371
|
-
report(
|
|
1372
|
-
{ line: detail.run.line, column: detail.run.column },
|
|
1373
|
-
`the language note for \`${inventory.language}\` must declare \`data.roman\``,
|
|
1374
|
-
);
|
|
1375
|
-
return;
|
|
1376
|
-
}
|
|
1377
|
-
for (const [from, to] of parts) {
|
|
1378
|
-
const plain = plainRomanRun(text, { from, to });
|
|
1379
|
-
for (const problem of romanTextProblems(plain.text, inventory))
|
|
1380
|
-
report(positionOf(starts, plain.offsets[problem.offset] ?? from), problem.message);
|
|
1381
|
-
}
|
|
1382
|
-
};
|
|
1383
|
-
|
|
1384
1372
|
/**
|
|
1385
1373
|
* Give a scroll fence the face it is set in, and report a fence that names
|
|
1386
1374
|
* none or whose script is written in lines. A fence that names no face
|
|
@@ -1646,7 +1634,128 @@ function analyseRuns(source, ctx, syntax) {
|
|
|
1646
1634
|
.filter((detail) => detail.run.kind === "span")
|
|
1647
1635
|
.map((detail) => [detail.run.start, detail]),
|
|
1648
1636
|
);
|
|
1649
|
-
|
|
1637
|
+
// Inline runs, spans and labelled links, of one resolved language and
|
|
1638
|
+
// encoding separated only by whitespace form one punctuation stream: a
|
|
1639
|
+
// sentence or a quotation may run through several of them. Each run opens
|
|
1640
|
+
// in the state its predecessor in the stream closed in, and quotations
|
|
1641
|
+
// still open are reported once, where the stream ends. A block (a div,
|
|
1642
|
+
// verse or scroll) is a stream of its own and never joins an inline run,
|
|
1643
|
+
// as a paragraph does not continue a sentence from outside it; it still
|
|
1644
|
+
// separates the runs on either side of it.
|
|
1645
|
+
const ownerDetails = details
|
|
1646
|
+
.filter((detail) => ownerOf(detail) === detail)
|
|
1647
|
+
.sort((a, b) => a.run.start - b.run.start);
|
|
1648
|
+
const streamEntry = (where, lang, enc, checkable, inline) => {
|
|
1649
|
+
const inventory = inventoryOf(lang);
|
|
1650
|
+
// A block's bounds are its opening and closing line indices.
|
|
1651
|
+
const end = Math.min(where.end, lines.length - 1);
|
|
1652
|
+
return {
|
|
1653
|
+
inline,
|
|
1654
|
+
start: inline ? where.start : starts[where.start],
|
|
1655
|
+
end: inline ? where.end : starts[end] + lines[end].length,
|
|
1656
|
+
line: where.line,
|
|
1657
|
+
column: where.column,
|
|
1658
|
+
lang,
|
|
1659
|
+
language: tagOf(lang)?.toLowerCase() ?? lang,
|
|
1660
|
+
enc,
|
|
1661
|
+
inventory,
|
|
1662
|
+
checked: checkable && Boolean(inventory?.characters),
|
|
1663
|
+
previous: undefined,
|
|
1664
|
+
continued: false,
|
|
1665
|
+
state: undefined,
|
|
1666
|
+
problems: undefined,
|
|
1667
|
+
};
|
|
1668
|
+
};
|
|
1669
|
+
const streamRuns = [
|
|
1670
|
+
...ownerDetails.map((detail) => ({
|
|
1671
|
+
...streamEntry(
|
|
1672
|
+
detail.run,
|
|
1673
|
+
detail.run.lang,
|
|
1674
|
+
detail.run.enc,
|
|
1675
|
+
Boolean(boundsOf(detail) && detail.checkRoman),
|
|
1676
|
+
detail.run.kind === "span",
|
|
1677
|
+
),
|
|
1678
|
+
detail,
|
|
1679
|
+
})),
|
|
1680
|
+
...rawReferences.map((reference) => ({
|
|
1681
|
+
...streamEntry(reference.where, reference.lang, reference.enc, true, true),
|
|
1682
|
+
reference,
|
|
1683
|
+
})),
|
|
1684
|
+
].sort((a, b) => a.start - b.start);
|
|
1685
|
+
for (const entry of streamRuns) {
|
|
1686
|
+
if (!entry.checked || !entry.inline) continue;
|
|
1687
|
+
const previous = streamRuns
|
|
1688
|
+
.filter((other) => other.end <= entry.start)
|
|
1689
|
+
.reduce((last, other) => (last && last.end >= other.end ? last : other), undefined);
|
|
1690
|
+
if (
|
|
1691
|
+
previous?.checked &&
|
|
1692
|
+
previous.inline &&
|
|
1693
|
+
previous.inventory === entry.inventory &&
|
|
1694
|
+
previous.language === entry.language &&
|
|
1695
|
+
previous.enc === entry.enc &&
|
|
1696
|
+
/^[\p{Zs}\t\r\n]*$/u.test(text.slice(previous.end, entry.start))
|
|
1697
|
+
) {
|
|
1698
|
+
entry.previous = previous;
|
|
1699
|
+
previous.continued = true;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
const streamRunOf = new Map(
|
|
1703
|
+
streamRuns.filter((entry) => entry.detail).map((entry) => [entry.detail, entry]),
|
|
1704
|
+
);
|
|
1705
|
+
/** The punctuation state a run opens in, a copy of its predecessor's. */
|
|
1706
|
+
const openingState = (entry) => {
|
|
1707
|
+
const previous = entry.previous && closingState(entry.previous);
|
|
1708
|
+
return previous ?
|
|
1709
|
+
{
|
|
1710
|
+
sentenceStart: previous.sentenceStart,
|
|
1711
|
+
quotes: previous.quotes.map((quote) => ({ ...quote })),
|
|
1712
|
+
}
|
|
1713
|
+
: romanPunctuationState();
|
|
1714
|
+
};
|
|
1715
|
+
/** A run's closing punctuation state, checking a labelled link on demand. */
|
|
1716
|
+
const closingState = (entry) => {
|
|
1717
|
+
if (entry.reference) checkReference(entry);
|
|
1718
|
+
return entry.state;
|
|
1719
|
+
};
|
|
1720
|
+
/**
|
|
1721
|
+
* Check a run's Roman text in its stream, giving findings by source offset.
|
|
1722
|
+
*
|
|
1723
|
+
* @param {any} entry - The run.
|
|
1724
|
+
* @param {string} roman - Its projected Roman text.
|
|
1725
|
+
* @param {(offset: number) => number} source - A text offset's source offset.
|
|
1726
|
+
*/
|
|
1727
|
+
const checkStream = (entry, roman, source) => {
|
|
1728
|
+
const state = openingState(entry);
|
|
1729
|
+
const problems = romanTextProblems(roman, entry.inventory, state).map((problem) => ({
|
|
1730
|
+
...problem,
|
|
1731
|
+
offset: source(problem.offset),
|
|
1732
|
+
}));
|
|
1733
|
+
for (const quote of state.quotes) quote.source ??= source(quote.offset);
|
|
1734
|
+
if (!entry.continued)
|
|
1735
|
+
problems.push(
|
|
1736
|
+
...romanUnclosedQuotations(
|
|
1737
|
+
state.quotes.map((quote) => ({ ...quote, offset: quote.source })),
|
|
1738
|
+
),
|
|
1739
|
+
);
|
|
1740
|
+
entry.state = state;
|
|
1741
|
+
return problems;
|
|
1742
|
+
};
|
|
1743
|
+
/** Check a labelled link's Roman source once, in its stream. */
|
|
1744
|
+
const checkReference = (entry) => {
|
|
1745
|
+
if (entry.problems) return entry.problems;
|
|
1746
|
+
entry.problems = [];
|
|
1747
|
+
if (!entry.checked) return entry.problems;
|
|
1748
|
+
const { reference } = entry;
|
|
1749
|
+
const plain = plainRomanRun(text, { from: reference.from, to: reference.to });
|
|
1750
|
+
entry.problems = checkStream(
|
|
1751
|
+
entry,
|
|
1752
|
+
plain.text,
|
|
1753
|
+
(offset) => plain.offsets[offset] ?? reference.from,
|
|
1754
|
+
);
|
|
1755
|
+
return entry.problems;
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1758
|
+
for (const owner of ownerDetails) {
|
|
1650
1759
|
const bounds = boundsOf(owner);
|
|
1651
1760
|
if (!bounds || !owner.checkRoman) continue;
|
|
1652
1761
|
const owned = details.filter((detail) => ownerOf(detail) === owner);
|
|
@@ -1703,12 +1812,15 @@ function analyseRuns(source, ctx, syntax) {
|
|
|
1703
1812
|
{ line: owner.run.line, column: owner.run.column },
|
|
1704
1813
|
`the language note for \`${inventory.language}\` must declare \`data.roman\``,
|
|
1705
1814
|
);
|
|
1706
|
-
else if (inventory)
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
)
|
|
1815
|
+
else if (inventory) {
|
|
1816
|
+
const entry = streamRunOf.get(owner);
|
|
1817
|
+
for (const problem of checkStream(
|
|
1818
|
+
entry,
|
|
1819
|
+
projection.text,
|
|
1820
|
+
(offset) => projection.offsets[offset] ?? bounds[0],
|
|
1821
|
+
))
|
|
1822
|
+
report(positionOf(starts, problem.offset), problem.message);
|
|
1823
|
+
}
|
|
1712
1824
|
for (const problem of projection.errors ?? [])
|
|
1713
1825
|
report(positionOf(starts, problem.offset), problem.message);
|
|
1714
1826
|
|
|
@@ -1830,16 +1942,15 @@ function analyseRuns(source, ctx, syntax) {
|
|
|
1830
1942
|
for (const detail of owned) detail.plan = plan;
|
|
1831
1943
|
}
|
|
1832
1944
|
|
|
1833
|
-
for (const
|
|
1834
|
-
const
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
});
|
|
1945
|
+
for (const entry of streamRuns.filter((entry) => entry.reference)) {
|
|
1946
|
+
const inventory = inventoryOf(entry.lang);
|
|
1947
|
+
if (inventory && !inventory.characters)
|
|
1948
|
+
report(
|
|
1949
|
+
{ line: entry.line, column: entry.column },
|
|
1950
|
+
`the language note for \`${inventory.language}\` must declare \`data.roman\``,
|
|
1951
|
+
);
|
|
1952
|
+
for (const problem of checkReference(entry))
|
|
1953
|
+
report(positionOf(starts, problem.offset), problem.message);
|
|
1843
1954
|
}
|
|
1844
1955
|
|
|
1845
1956
|
for (const reference of independentReferences) {
|
|
@@ -2484,7 +2595,13 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
|
|
|
2484
2595
|
});
|
|
2485
2596
|
if (positions.length)
|
|
2486
2597
|
patches.push({ from: positions[0], to: positions.at(-1) + 1, text: edit.text });
|
|
2487
|
-
else if (
|
|
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.
|
|
2488
2605
|
patches.push({ from: line.text.length, to: line.text.length, text: edit.text });
|
|
2489
2606
|
}
|
|
2490
2607
|
let result = line.text;
|
package/engine/content-verse.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
233
|
-
*
|
|
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 =
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/engine/encoding.mjs
CHANGED
|
@@ -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: "
|
|
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))
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -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
|
-
|
|
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) => {
|
|
@@ -109,9 +109,14 @@ export function romanInventoriesOf(records, languages = {}) {
|
|
|
109
109
|
* No punctuation, case or normalization repair is performed.
|
|
110
110
|
* @param {string} text - Visible Roman source.
|
|
111
111
|
* @param {RomanInventory} inventory - Its language's declared characters.
|
|
112
|
+
* @param {RomanPunctuationState} [stream] - The punctuation state of a stream
|
|
113
|
+
* of runs the text continues, from {@link romanPunctuationState}; updated to
|
|
114
|
+
* the state the text closes in. Its unclosed quotations are left for
|
|
115
|
+
* {@link romanUnclosedQuotations} at the end of the stream. Without it the
|
|
116
|
+
* text is a stream of its own and its unclosed quotations are reported.
|
|
112
117
|
* @returns {Array<{offset: number, message: string}>}
|
|
113
118
|
*/
|
|
114
|
-
export function romanTextProblems(text, inventory) {
|
|
119
|
+
export function romanTextProblems(text, inventory, stream) {
|
|
115
120
|
const out = [];
|
|
116
121
|
if (!inventory.characters) return out;
|
|
117
122
|
const decomposed = new Map(
|
|
@@ -119,8 +124,9 @@ export function romanTextProblems(text, inventory) {
|
|
|
119
124
|
.filter(({ segment }) => segment !== segment.normalize("NFC"))
|
|
120
125
|
.map(({ segment, index }) => [index, segment]),
|
|
121
126
|
);
|
|
122
|
-
const
|
|
123
|
-
|
|
127
|
+
const state = stream ?? romanPunctuationState();
|
|
128
|
+
const { quotes } = state;
|
|
129
|
+
let sentenceStart = state.sentenceStart;
|
|
124
130
|
const sentenceText = () => {
|
|
125
131
|
sentenceStart = false;
|
|
126
132
|
if (quotes.length) quotes.at(-1).hasText = true;
|
|
@@ -245,10 +251,41 @@ export function romanTextProblems(text, inventory) {
|
|
|
245
251
|
} else sentenceText();
|
|
246
252
|
offset += char.length;
|
|
247
253
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
opening.offset,
|
|
251
|
-
`quotation \`${opening.char}\` needs its closing \`${opening.char === "«" ? "»" : "›"}\``,
|
|
252
|
-
);
|
|
254
|
+
state.sentenceStart = sentenceStart;
|
|
255
|
+
if (!stream) out.push(...romanUnclosedQuotations(quotes));
|
|
253
256
|
return out.sort((a, b) => a.offset - b.offset);
|
|
254
257
|
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @typedef {object} RomanQuotation
|
|
261
|
+
* @property {string} char - The opening mark, `«` or `‹`.
|
|
262
|
+
* @property {number} offset - Its offset in the text that opened it.
|
|
263
|
+
* @property {boolean} sentenceStart - Whether its sentence had text before it.
|
|
264
|
+
* @property {boolean} hasText - Whether it holds sentence text.
|
|
265
|
+
*/
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @typedef {object} RomanPunctuationState
|
|
269
|
+
* @property {boolean} sentenceStart - Whether no sentence text precedes the next mark.
|
|
270
|
+
* @property {RomanQuotation[]} quotes - Open quotations, outermost first.
|
|
271
|
+
*/
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The punctuation state at the start of a stream of runs.
|
|
275
|
+
* @returns {RomanPunctuationState}
|
|
276
|
+
*/
|
|
277
|
+
export function romanPunctuationState() {
|
|
278
|
+
return { sentenceStart: true, quotes: [] };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Findings for quotations still open at the end of a stream.
|
|
283
|
+
* @param {ReadonlyArray<RomanQuotation>} quotes - The open quotations.
|
|
284
|
+
* @returns {Array<{offset: number, message: string}>} At each opening's `offset`.
|
|
285
|
+
*/
|
|
286
|
+
export function romanUnclosedQuotations(quotes) {
|
|
287
|
+
return quotes.map((opening) => ({
|
|
288
|
+
offset: opening.offset,
|
|
289
|
+
message: `quotation \`${opening.char}\` needs its closing \`${opening.char === "«" ? "»" : "›"}\``,
|
|
290
|
+
}));
|
|
291
|
+
}
|
package/engine/verse-metre.mjs
CHANGED
|
@@ -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 {
|
|
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,
|
|
173
|
-
const 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
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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.
|
|
4
|
-
*
|
|
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.
|
|
@@ -26,9 +26,40 @@ export function romanInventoriesOf(records: ReadonlyMap<string, Record<string, a
|
|
|
26
26
|
* No punctuation, case or normalization repair is performed.
|
|
27
27
|
* @param {string} text - Visible Roman source.
|
|
28
28
|
* @param {RomanInventory} inventory - Its language's declared characters.
|
|
29
|
+
* @param {RomanPunctuationState} [stream] - The punctuation state of a stream
|
|
30
|
+
* of runs the text continues, from {@link romanPunctuationState}; updated to
|
|
31
|
+
* the state the text closes in. Its unclosed quotations are left for
|
|
32
|
+
* {@link romanUnclosedQuotations} at the end of the stream. Without it the
|
|
33
|
+
* text is a stream of its own and its unclosed quotations are reported.
|
|
29
34
|
* @returns {Array<{offset: number, message: string}>}
|
|
30
35
|
*/
|
|
31
|
-
export function romanTextProblems(text: string, inventory: RomanInventory): Array<{
|
|
36
|
+
export function romanTextProblems(text: string, inventory: RomanInventory, stream?: RomanPunctuationState): Array<{
|
|
37
|
+
offset: number;
|
|
38
|
+
message: string;
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {object} RomanQuotation
|
|
42
|
+
* @property {string} char - The opening mark, `«` or `‹`.
|
|
43
|
+
* @property {number} offset - Its offset in the text that opened it.
|
|
44
|
+
* @property {boolean} sentenceStart - Whether its sentence had text before it.
|
|
45
|
+
* @property {boolean} hasText - Whether it holds sentence text.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} RomanPunctuationState
|
|
49
|
+
* @property {boolean} sentenceStart - Whether no sentence text precedes the next mark.
|
|
50
|
+
* @property {RomanQuotation[]} quotes - Open quotations, outermost first.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* The punctuation state at the start of a stream of runs.
|
|
54
|
+
* @returns {RomanPunctuationState}
|
|
55
|
+
*/
|
|
56
|
+
export function romanPunctuationState(): RomanPunctuationState;
|
|
57
|
+
/**
|
|
58
|
+
* Findings for quotations still open at the end of a stream.
|
|
59
|
+
* @param {ReadonlyArray<RomanQuotation>} quotes - The open quotations.
|
|
60
|
+
* @returns {Array<{offset: number, message: string}>} At each opening's `offset`.
|
|
61
|
+
*/
|
|
62
|
+
export function romanUnclosedQuotations(quotes: ReadonlyArray<RomanQuotation>): Array<{
|
|
32
63
|
offset: number;
|
|
33
64
|
message: string;
|
|
34
65
|
}>;
|
|
@@ -43,3 +74,31 @@ export type RomanInventory = {
|
|
|
43
74
|
*/
|
|
44
75
|
characters: ReadonlySet<string> | undefined;
|
|
45
76
|
};
|
|
77
|
+
export type RomanQuotation = {
|
|
78
|
+
/**
|
|
79
|
+
* - The opening mark, `«` or `‹`.
|
|
80
|
+
*/
|
|
81
|
+
char: string;
|
|
82
|
+
/**
|
|
83
|
+
* - Its offset in the text that opened it.
|
|
84
|
+
*/
|
|
85
|
+
offset: number;
|
|
86
|
+
/**
|
|
87
|
+
* - Whether its sentence had text before it.
|
|
88
|
+
*/
|
|
89
|
+
sentenceStart: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* - Whether it holds sentence text.
|
|
92
|
+
*/
|
|
93
|
+
hasText: boolean;
|
|
94
|
+
};
|
|
95
|
+
export type RomanPunctuationState = {
|
|
96
|
+
/**
|
|
97
|
+
* - Whether no sentence text precedes the next mark.
|
|
98
|
+
*/
|
|
99
|
+
sentenceStart: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* - Open quotations, outermost first.
|
|
102
|
+
*/
|
|
103
|
+
quotes: RomanQuotation[];
|
|
104
|
+
};
|