@heroiclands/package-build 25.5.0 → 25.5.2

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,17 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 25.5.2
4
+
5
+ **Languages and scripts**
6
+
7
+ - 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.
8
+
9
+ ## 25.5.1
10
+
11
+ **Builds and commands**
12
+
13
+ - `package-build markdown` checks a repository of any size, rather than crashing on large ones.
14
+
3
15
  ## 25.5.0
4
16
 
5
17
  **Languages and scripts**
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 | 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)` | `{offset, message}[]` | validating standard Roman punctuation and language additions with exact UTF-16 offsets |
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
 
@@ -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 { romanInventoriesOf, romanTextProblems } from "./roman-characters.mjs";
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
- for (const owner of details.filter((detail) => ownerOf(detail) === detail)) {
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
- for (const problem of romanTextProblems(projection.text, inventory))
1708
- report(
1709
- positionOf(starts, projection.offsets[problem.offset] ?? bounds[0]),
1710
- problem.message,
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 reference of rawReferences) {
1834
- const font = reference.font;
1835
- roman([[reference.from, reference.to]], {
1836
- run: {
1837
- lang: reference.lang,
1838
-
1839
- ...reference.where,
1840
- },
1841
- face: font === undefined ? undefined : faces.get(font),
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) {
@@ -451,6 +451,9 @@ function gitignoreHooks(root) {
451
451
  };
452
452
  }
453
453
 
454
+ /** Sources per markdownlint call: it lints `strings` by recursing once per entry, so a large set overflows the stack. */
455
+ const LINT_BATCH_SIZE = 200;
456
+
454
457
  /**
455
458
  * Lint markdown under a root with the `markdownlint` library.
456
459
  *
@@ -560,10 +563,19 @@ export async function runMarkdownlint(params) {
560
563
  }
561
564
  const lintSources = async (selected) => {
562
565
  const { files: _files, ...shared } = options;
563
- const results = await lint({
564
- ...shared,
565
- strings: Object.fromEntries(selected.map((file) => [file, sources.get(file).text])),
566
- });
566
+ const results = {};
567
+ for (let start = 0; start < selected.length; start += LINT_BATCH_SIZE) {
568
+ const batch = selected.slice(start, start + LINT_BATCH_SIZE);
569
+ Object.assign(
570
+ results,
571
+ await lint({
572
+ ...shared,
573
+ strings: Object.fromEntries(
574
+ batch.map((file) => [file, sources.get(file).text]),
575
+ ),
576
+ }),
577
+ );
578
+ }
567
579
  for (const file of selected) {
568
580
  const protectedSource = sources.get(file);
569
581
  results[file] = (results[file] ?? [])
@@ -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 quotes = [];
123
- let sentenceStart = true;
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
- for (const opening of quotes)
249
- report(
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "25.5.0",
3
+ "version": "25.5.2",
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",
@@ -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
+ };