@heroiclands/package-build 25.1.0 → 25.1.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.
@@ -1014,57 +1014,60 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
1014
1014
 
1015
1015
  **Inner keys** — a key a field's value does not declare is an error at its own line and column.
1016
1016
 
1017
- | Key | Shape | Required | Meaning |
1018
- | ------------------------------------------------------ | ------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1019
- | `data.rules.normalization` | "NFC" | yes | Required value: "NFC". |
1020
- | `data.rules.matching` | "longest-first" | yes | Required value: "longest-first". |
1021
- | `data.rules.case` | a map | no | Case handling for letters and consonant groups. |
1022
- | `data.rules.case.single-letter` | "preserve" | yes | Required value: "preserve". |
1023
- | `data.rules.case.consonant-group` | "lowercase-or-initial-uppercase" | yes | Required value: "lowercase-or-initial-uppercase". |
1024
- | `data.rules.case.other-group-cases` | "forbidden" | yes | Required value: "forbidden". |
1025
- | `data.rules.stress` | a map | no | The font's nonprinting stress modifier after an acute vowel. |
1026
- | `data.rules.stress.modifier` | a string | yes | The font code for U+E0114. |
1027
- | `data.rules.stress.position` | "after-stressed-vowel" | yes | Required value: "after-stressed-vowel". |
1028
- | `data.rules.stress.printing` | false | yes | Required value: false. |
1029
- | `data.rules.stress.effect` | "stress-only" | yes | Required value: "stress-only". |
1030
- | `data.rules.stress.changes-sound` | false | yes | Required value: false. |
1031
- | `data.rules.stress.changes-length` | false | yes | Required value: false. |
1032
- | `data.rules.stress.vowel-group-position` | "first-vowel" | yes | Required value: "first-vowel". |
1033
- | `data.rules.vowel-groups` | a map | no | Explicit recognized vowel groups and their acute placement. |
1034
- | `data.rules.vowel-groups.reading` | "established" | yes | Required value: "established". |
1035
- | `data.rules.vowel-groups.allow-splitting` | false | yes | Required value: false. |
1036
- | `data.rules.vowel-groups.unrecognized-adjacent-vowels` | "separate" | yes | Required value: "separate". |
1037
- | `data.rules.vowel-groups.groups` | a list of vowel groups | yes | The complete authored group inventory. |
1038
- | `data.rules.diaeresis` | a map | no | Whether diaeresis mappings are allowed. |
1039
- | `data.rules.diaeresis.allowed` | false | yes | Required value: false. |
1040
- | `data.rules.grave-accent` | a map | no | Whether grave-accent mappings are allowed. |
1041
- | `data.rules.grave-accent.allowed` | false | yes | Required value: false. |
1042
- | `data.rules.contextual-mappings` | a list of conditional mappings | no | Before-vowel alternatives applied before unconditional mappings. |
1043
- | `data.rules.contextual-mappings[].match` | a string | yes | The source sequence. |
1044
- | `data.rules.contextual-mappings[].before` | "vowel" | yes | Required value: "vowel". |
1045
- | `data.rules.contextual-mappings[].emit` | a glyph code or list of codes | yes | The font codes written by this alternative. |
1046
- | `data.rules.y` | a map | no | The consonant reading of y. |
1047
- | `data.rules.y.kind` | "consonant" | yes | Required value: "consonant". |
1048
- | `data.rules.y.sound` | "y in yes" | yes | Required value: "y in yes". |
1049
- | `data.rules.y.vowel-reading` | false | yes | Required value: false. |
1050
- | `data.concepts[].id` | lowercase letters, digits and `-` | yes | The concept's id, which a word's `concept` names. Unique in the package. |
1051
- | `data.concepts[].gloss` | a short gloss, such as `a smell; a scent` | yes | What the concept means, in brief. |
1052
- | `data.concepts[].group` | a group name, such as `senses` | yes | The group the report lists the concept under. |
1053
- | `data.sounds.letters` | list of `{ letters, kind, like?, place?, ipa?, tongue? }` | no | The letters the language writes, and the sounds they stand for. |
1054
- | `data.sounds.letters[].letters` | a list of letters | yes | The letters or digraphs the row describes, as written. |
1055
- | `data.sounds.letters[].kind` | a string, such as `consonant` or `long vowel` | yes | What kind of sound they write. |
1056
- | `data.sounds.letters[].like` | a string | no | What they sound like; absent where the row states only their kind and place. |
1057
- | `data.sounds.letters[].place` | a string, such as `lips` or `tongue-tip` | no | Where in the mouth they are made. |
1058
- | `data.sounds.letters[].ipa` | a string in the International Phonetic Alphabet, such as `ʃ` | no | The sounds they write, in the International Phonetic Alphabet. |
1059
- | `data.sounds.letters[].tongue` | a tag or a language note's Address, or a list of them | no | The tongues that write them, each a tag `data.tongues` declares or the Address of a language note; every one where absent. A `doc` note's rows name language notes only. |
1060
- | `data.sounds.marks` | list of `{ mark, letters, kind, limit }` | no | The marks the language writes on its letters. |
1061
- | `data.sounds.marks[].mark` | a string, such as `acute` | yes | The mark's name. |
1062
- | `data.sounds.marks[].letters` | a list of letters | yes | The letters that carry it. |
1063
- | `data.sounds.marks[].kind` | one of stress, length, apart | yes | What the mark says of its letter. |
1064
- | `data.sounds.marks[].limit` | one of one, any | yes | How many a word may carry: `one` at most, or `any`. |
1065
- | `data.metres[].name` | string | yes | What a verse fence's `scan=` names the metre by: lowercase letters, digits and `-`, unique in the package. |
1066
- | `data.metres[].beats` | a whole number above zero | yes | The beats of one line. |
1067
- | `data.metres[].kind` | one of accentual | yes | How the beats are counted: `accentual`, one beat for each stressed syllable the language's stress rule and the beat convention give. |
1017
+ | Key | Shape | Required | Meaning |
1018
+ | ------------------------------------------------------ | --------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1019
+ | `data.rules.normalization` | "NFC" | yes | Required value: "NFC". |
1020
+ | `data.rules.matching` | "longest-first" | yes | Required value: "longest-first". |
1021
+ | `data.rules.roundTrip` | a string | no | Exact by default; canonical permits aliases and empty code sequences and decodes an authored conventional spelling. |
1022
+ | `data.rules.word-separators` | a list of nonempty NFC source sequences | no | Source word dividers written as shared spaces, after contextual alternatives; cannot also be ordinary mappings or replace shared punctuation. |
1023
+ | `data.rules.case` | a map | no | Case handling for letters and consonant groups. |
1024
+ | `data.rules.case.single-letter` | "preserve" | yes | Required value: "preserve". |
1025
+ | `data.rules.case.consonant-group` | "lowercase-or-initial-uppercase" | yes | Required value: "lowercase-or-initial-uppercase". |
1026
+ | `data.rules.case.other-group-cases` | "forbidden" | yes | Required value: "forbidden". |
1027
+ | `data.rules.stress` | a map | no | The font's nonprinting stress modifier after an acute vowel. |
1028
+ | `data.rules.stress.modifier` | a string | yes | The font code for U+E0114. |
1029
+ | `data.rules.stress.position` | "after-stressed-vowel" | yes | Required value: "after-stressed-vowel". |
1030
+ | `data.rules.stress.printing` | false | yes | Required value: false. |
1031
+ | `data.rules.stress.effect` | "stress-only" | yes | Required value: "stress-only". |
1032
+ | `data.rules.stress.changes-sound` | false | yes | Required value: false. |
1033
+ | `data.rules.stress.changes-length` | false | yes | Required value: false. |
1034
+ | `data.rules.stress.vowel-group-position` | "first-vowel" | yes | Required value: "first-vowel". |
1035
+ | `data.rules.vowel-groups` | a map | no | Explicit recognized vowel groups and their acute placement. |
1036
+ | `data.rules.vowel-groups.reading` | "established" | yes | Required value: "established". |
1037
+ | `data.rules.vowel-groups.allow-splitting` | false | yes | Required value: false. |
1038
+ | `data.rules.vowel-groups.unrecognized-adjacent-vowels` | "separate" | yes | Required value: "separate". |
1039
+ | `data.rules.vowel-groups.groups` | a list of vowel groups | yes | The complete authored group inventory. |
1040
+ | `data.rules.diaeresis` | a map | no | Whether diaeresis mappings are allowed. |
1041
+ | `data.rules.diaeresis.allowed` | true or false | yes | False forbids the accent; true permits explicitly mapped and accepted characters. |
1042
+ | `data.rules.grave-accent` | a map | no | Whether grave-accent mappings are allowed. |
1043
+ | `data.rules.grave-accent.allowed` | true or false | yes | False forbids the accent; true permits explicitly mapped and accepted characters. |
1044
+ | `data.rules.contextual-mappings` | a list of conditional mappings | no | Alternatives conditioned on the following letter or source position, applied before unconditional mappings. |
1045
+ | `data.rules.contextual-mappings[].match` | a string | yes | The source sequence. |
1046
+ | `data.rules.contextual-mappings[].before` | a string | no | Match before a declared vowel or another letter. |
1047
+ | `data.rules.contextual-mappings[].at` | a string | no | Match at word start, word end or line start. |
1048
+ | `data.rules.contextual-mappings[].emit` | a glyph code or list of codes (empty only with canonical round trips) | yes | The font codes written by this alternative. |
1049
+ | `data.rules.y` | a map | no | The consonant reading of y. |
1050
+ | `data.rules.y.kind` | "consonant" | yes | Required value: "consonant". |
1051
+ | `data.rules.y.sound` | "y in yes" | yes | Required value: "y in yes". |
1052
+ | `data.rules.y.vowel-reading` | false | yes | Required value: false. |
1053
+ | `data.concepts[].id` | lowercase letters, digits and `-` | yes | The concept's id, which a word's `concept` names. Unique in the package. |
1054
+ | `data.concepts[].gloss` | a short gloss, such as `a smell; a scent` | yes | What the concept means, in brief. |
1055
+ | `data.concepts[].group` | a group name, such as `senses` | yes | The group the report lists the concept under. |
1056
+ | `data.sounds.letters` | list of `{ letters, kind, like?, place?, ipa?, tongue? }` | no | The letters the language writes, and the sounds they stand for. |
1057
+ | `data.sounds.letters[].letters` | a list of letters | yes | The letters or digraphs the row describes, as written. |
1058
+ | `data.sounds.letters[].kind` | a string, such as `consonant` or `long vowel` | yes | What kind of sound they write. |
1059
+ | `data.sounds.letters[].like` | a string | no | What they sound like; absent where the row states only their kind and place. |
1060
+ | `data.sounds.letters[].place` | a string, such as `lips` or `tongue-tip` | no | Where in the mouth they are made. |
1061
+ | `data.sounds.letters[].ipa` | a string in the International Phonetic Alphabet, such as `ʃ` | no | The sounds they write, in the International Phonetic Alphabet. |
1062
+ | `data.sounds.letters[].tongue` | a tag or a language note's Address, or a list of them | no | The tongues that write them, each a tag `data.tongues` declares or the Address of a language note; every one where absent. A `doc` note's rows name language notes only. |
1063
+ | `data.sounds.marks` | list of `{ mark, letters, kind, limit }` | no | The marks the language writes on its letters. |
1064
+ | `data.sounds.marks[].mark` | a string, such as `acute` | yes | The mark's name. |
1065
+ | `data.sounds.marks[].letters` | a list of letters | yes | The letters that carry it. |
1066
+ | `data.sounds.marks[].kind` | one of stress, length, apart | yes | What the mark says of its letter. |
1067
+ | `data.sounds.marks[].limit` | one of one, any | yes | How many a word may carry: `one` at most, or `any`. |
1068
+ | `data.metres[].name` | string | yes | What a verse fence's `scan=` names the metre by: lowercase letters, digits and `-`, unique in the package. |
1069
+ | `data.metres[].beats` | a whole number above zero | yes | The beats of one line. |
1070
+ | `data.metres[].kind` | one of accentual | yes | How the beats are counted: `accentual`, one beat for each stressed syllable the language's stress rule and the beat convention give. |
1068
1071
 
1069
1072
  ### macro
1070
1073
 
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import MarkdownIt from "markdown-it";
15
+ import { blockTokens } from "./markdown-blocks.mjs";
15
16
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
16
17
  import { slugify } from "./content-slug.mjs";
17
18
 
@@ -48,7 +49,6 @@ export const ALERT_TYPES = Object.freeze({
48
49
  ),
49
50
  },
50
51
  });
51
- const parser = new MarkdownIt({ html: true });
52
52
  const titleParser = new MarkdownIt({ html: false });
53
53
  const marker = /^ {0,3}>[ \t]?\[!([^\]\n]+)\](.*)$/;
54
54
  /** Read alerts outside code examples, retaining inclusive source line ranges.
@@ -60,7 +60,7 @@ export function scanAlerts(source) {
60
60
  blocks = [],
61
61
  errors = [];
62
62
  let depth = 0;
63
- for (const token of parser.parse(text, {})) {
63
+ for (const token of blockTokens(text)) {
64
64
  if (token.type === "blockquote_close") {
65
65
  depth--;
66
66
  continue;
@@ -52,6 +52,7 @@
52
52
  import crypto from "node:crypto";
53
53
  import { slugify } from "./content-slug.mjs";
54
54
  import MarkdownIt from "markdown-it";
55
+ import { blockTokens } from "./markdown-blocks.mjs";
55
56
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
56
57
  import { scanVerse, renderVerse } from "./content-verse.mjs";
57
58
  import { renderScroll, scanScrolls } from "./content-scroll.mjs";
@@ -63,7 +64,6 @@ import { WITHHELD_CLASS, parseHeadingLine, withheldSections } from "./heading-at
63
64
  * and a stray `<section>` would close the block early.
64
65
  */
65
66
  const titleParser = new MarkdownIt({ html: false });
66
- const structureParser = new MarkdownIt({ html: true });
67
67
 
68
68
  /** The blocks an author may open, with the heading each takes by default. */
69
69
  export const BLOCK_NAMES = Object.freeze({
@@ -108,7 +108,7 @@ export function scanBlocks(source) {
108
108
  // A bare fence can belong to a quote through lazy continuation.
109
109
  // Parser ranges decide whether it can delimit a container.
110
110
  const literalLines = new Set();
111
- for (const token of structureParser.parse(String(source ?? ""), {})) {
111
+ for (const token of blockTokens(source)) {
112
112
  if (
113
113
  !token.map ||
114
114
  !["blockquote_open", "fence", "code_block", "html_block"].includes(token.type)
@@ -1087,6 +1087,8 @@ export function resolveCultures(
1087
1087
  * @param {Map<string, {raw: string}>} [options.sources] - Selected captured source strings.
1088
1088
  * @param {import("./content-session.mjs").ContentSyntax} [options.syntax] - Session scanner projections.
1089
1089
  * @param {Iterable<string>} [options.nameTypes] - Additional bare name kinds from configured type declarations.
1090
+ * @param {ReturnType<typeof import("./content-timings.mjs").createContentTimings>} [options.timings] -
1091
+ * Invocation stage accounting, which records each phase of the derivation.
1090
1092
  * @returns {Array<Record<string, any>>} The records, in a total order that does
1091
1093
  * not depend on directory-read order.
1092
1094
  */
@@ -1109,8 +1111,16 @@ export function collectContentIndex(
1109
1111
  sources,
1110
1112
  syntax,
1111
1113
  nameTypes,
1114
+ timings,
1112
1115
  },
1113
1116
  ) {
1117
+ // Each phase runs until the next begins; the caller's stage closes the last.
1118
+ let phaseEnd;
1119
+ const phase = (name) => {
1120
+ phaseEnd?.();
1121
+ phaseEnd = timings?.start(name);
1122
+ };
1123
+ phase("index read");
1114
1124
  const records = [];
1115
1125
  // Passed through rather than defaulted away: an absent scope is the
1116
1126
  // caller's omission, and `walkMarkdownTree` says so.
@@ -1175,6 +1185,7 @@ export function collectContentIndex(
1175
1185
  );
1176
1186
 
1177
1187
  // Each recorded note with its body, for the words its prose cites.
1188
+ phase("index notes");
1178
1189
  const bodies = [];
1179
1190
  for (const { frontmatter, body, bodyLine, bodyColumn, raw, absPath } of parsedNotes) {
1180
1191
  // Before the index derives anything onto the frontmatter, so a later
@@ -1251,16 +1262,19 @@ export function collectContentIndex(
1251
1262
 
1252
1263
  // The words each note's prose cites, read once every language's words
1253
1264
  // are recorded; the index keeps the citing Addresses and no text.
1265
+ phase("index citations");
1254
1266
  attachCitations(records, bodies);
1255
1267
 
1256
1268
  // A run marked with a language alone takes its language's script, which
1257
1269
  // is read once every language note is recorded.
1270
+ phase("index encoding runs");
1258
1271
  fillEncodingRuns(records, { languages, contentPackage, rootDir });
1259
1272
 
1260
1273
  // Every note record exists and every Address in it is a tuple, so a parent
1261
1274
  // is found by its canonical address. Before the sort, which reads nothing
1262
1275
  // the pass writes. Calendars first: they read the culture a note states,
1263
1276
  // which the culture pass then replaces with the one it resolves.
1277
+ phase("index calendars and cultures");
1264
1278
  resolveNoteCalendars(records, {
1265
1279
  contentPackage,
1266
1280
  defaultCalendar,
@@ -1298,6 +1312,7 @@ export function collectContentIndex(
1298
1312
  // projection as the surfaces, without making the index emitter async.
1299
1313
  // Public notes resolve providers in their audience, including lineage
1300
1314
  // through hidden languages, just as their build does.
1315
+ phase("index lexical sections");
1301
1316
  const visible = records.filter((record) => !isGmNote(record));
1302
1317
  const publicSections = lexicalSectionsFor(visible, records);
1303
1318
  const allSections = lexicalSectionsFor(records);
@@ -1319,6 +1334,7 @@ export function collectContentIndex(
1319
1334
  // about the content, and the artifact is meant to be byte-identical across
1320
1335
  // two runs over an unchanged tree. Collected above, ahead of the notes —
1321
1336
  // see `resolveRole` — rather than walked a second time here.
1337
+ phase("index order");
1322
1338
  for (const record of assetRecords) {
1323
1339
  records.push(/** @type {Record<string, any>} */ (sortKeysDeep(record)));
1324
1340
  }
@@ -1344,9 +1360,11 @@ export function collectContentIndex(
1344
1360
  ) ||
1345
1361
  String(a.id ?? "").localeCompare(String(b.id ?? ""), "en"),
1346
1362
  );
1347
- return records.map((record) =>
1363
+ const decoded = records.map((record) =>
1348
1364
  decodeIndexAddresses(record, addressContext ?? { package: contentPackage }),
1349
1365
  );
1366
+ phaseEnd?.();
1367
+ return decoded;
1350
1368
  }
1351
1369
 
1352
1370
  /**
@@ -1465,6 +1483,8 @@ function dependencyAddresses(config) {
1465
1483
  * @param {import("./content-session.mjs").ContentSyntax} [opts.syntax] - Session scanner projections.
1466
1484
  * @param {Iterable<string>} [opts.nameTypes] - Bare name kinds, defaulting to configured item types.
1467
1485
  * @param {Map<string, object>} [opts.foreignIndex] - Captured dependency address view.
1486
+ * @param {ReturnType<typeof import("./content-timings.mjs").createContentTimings>} [opts.timings] -
1487
+ * Invocation stage accounting.
1468
1488
  * @returns {object[]} One record per note, plus one per documentation entry.
1469
1489
  */
1470
1490
  export function indexRecordsFor({
@@ -1478,6 +1498,7 @@ export function indexRecordsFor({
1478
1498
  syntax,
1479
1499
  nameTypes,
1480
1500
  foreignIndex,
1501
+ timings,
1481
1502
  } = {}) {
1482
1503
  const resolved = config ?? loadPackConfig();
1483
1504
  const tree = contentBase ?? resolved.paths.content;
@@ -1512,6 +1533,7 @@ export function indexRecordsFor({
1512
1533
  sources,
1513
1534
  syntax,
1514
1535
  nameTypes: nameTypes ?? resolved.itemTypes,
1536
+ timings,
1515
1537
  });
1516
1538
  }
1517
1539
 
@@ -41,6 +41,7 @@
41
41
  */
42
42
 
43
43
  import MarkdownIt from "markdown-it";
44
+ import { blockTokens } from "./markdown-blocks.mjs";
44
45
 
45
46
  import { scanBlocks, BLOCK_CONTAINERS } from "./content-blocks.mjs";
46
47
  import { scanVerse, hasColumns, verseLine, verseGroups, STANZA_BREAK } from "./content-verse.mjs";
@@ -737,9 +738,6 @@ function codeName(point) {
737
738
  return `U+${point.toString(16).toUpperCase().padStart(4, "0")}`;
738
739
  }
739
740
 
740
- /** A parser for the block structure of a div's body. */
741
- const blockParser = new MarkdownIt({ html: true });
742
-
743
741
  /** What a block token is called in a finding. */
744
742
  const BLOCK_WHAT = Object.freeze({
745
743
  heading_open: "a heading",
@@ -1485,7 +1483,7 @@ function analyseRuns(source, ctx, syntax) {
1485
1483
  { line: first + line + 1, column: /^ */.exec(body[line])[0].length + 1 },
1486
1484
  `a div marked \`${marking}\` holds paragraphs and line breaks only, not ${what}`,
1487
1485
  );
1488
- for (const token of blockParser.parse(body.join("\n"), {})) {
1486
+ for (const token of blockTokens(body.join("\n"))) {
1489
1487
  if (token.level !== 0 || !token.map) continue;
1490
1488
  const [top, past] = token.map;
1491
1489
  if (token.type === "paragraph_open") {
@@ -1535,7 +1533,14 @@ function analyseRuns(source, ctx, syntax) {
1535
1533
  const boundsOf = (detail) =>
1536
1534
  detail.reference ??
1537
1535
  (detail.run.kind === "span" ? [detail.run.start + 1, detail.contentEnd] : detail.body);
1536
+ // Each run's parent and owner are read many times while ownership is
1537
+ // assigned below, so each is found once.
1538
+ const parents = new Map();
1538
1539
  const parentOf = (detail) => {
1540
+ if (!parents.has(detail)) parents.set(detail, findParent(detail));
1541
+ return parents.get(detail);
1542
+ };
1543
+ const findParent = (detail) => {
1539
1544
  if (detail.run.kind !== "span" || Object.keys(detail.keys).length) return undefined;
1540
1545
  return details
1541
1546
  .filter(
@@ -1547,9 +1552,13 @@ function analyseRuns(source, ctx, syntax) {
1547
1552
  )
1548
1553
  .sort((a, b) => boundsOf(a)[1] - boundsOf(a)[0] - (boundsOf(b)[1] - boundsOf(b)[0]))[0];
1549
1554
  };
1555
+ const owners = new Map();
1550
1556
  const ownerOf = (detail) => {
1551
- const parent = parentOf(detail);
1552
- return parent ? ownerOf(parent) : detail;
1557
+ if (!owners.has(detail)) {
1558
+ const parent = parentOf(detail);
1559
+ owners.set(detail, parent ? ownerOf(parent) : detail);
1560
+ }
1561
+ return owners.get(detail);
1553
1562
  };
1554
1563
  const wholeVerseReading = (detail) => {
1555
1564
  if (detail.run.kind === "verse") return true;
@@ -42,7 +42,7 @@
42
42
  * @module
43
43
  */
44
44
 
45
- import MarkdownIt from "markdown-it";
45
+ import { blockTokens } from "./markdown-blocks.mjs";
46
46
 
47
47
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
48
48
 
@@ -78,8 +78,6 @@ export const BOOK_SCROLL_ROWS = 40;
78
78
  /** The class every scroll carries. */
79
79
  export const SCROLL_CLASS = "scroll";
80
80
 
81
- const structureParser = new MarkdownIt({ html: true });
82
-
83
81
  /**
84
82
  * One `scroll` fence.
85
83
  *
@@ -129,7 +127,7 @@ export function scanScrolls(source) {
129
127
  if (!/^ *(?:`{3,}|~{3,}) *scroll(?:\s|$)/m.test(text)) return { blocks, errors };
130
128
  const lines = text.split("\n");
131
129
  const literalLines = new Set();
132
- for (const token of structureParser.parse(text, {})) {
130
+ for (const token of blockTokens(text)) {
133
131
  if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
134
132
  for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
135
133
  }
@@ -144,6 +144,7 @@ export async function createContentSession({
144
144
  problems,
145
145
  foreignIndex: foreign.index,
146
146
  syntax,
147
+ timings,
147
148
  }),
148
149
  );
149
150
  const memo = (key, make) => {
@@ -24,12 +24,15 @@ export function createContentTimings() {
24
24
  stages.set(name, (stages.get(name) ?? 0) + now - changed);
25
25
  changed = now;
26
26
  };
27
+ // Ending a stage also ends any stage opened inside it and left open.
27
28
  const begin = (name) => {
28
29
  account();
30
+ const depth = stack.length;
29
31
  stack.push(name);
30
32
  return () => {
33
+ if (stack.length <= depth) return;
31
34
  account();
32
- stack.pop();
35
+ stack.length = depth;
33
36
  };
34
37
  };
35
38
  return {
@@ -51,6 +54,15 @@ export function createContentTimings() {
51
54
  stop();
52
55
  }
53
56
  },
57
+ /**
58
+ * Open a stage that runs until the returned function is called, or
59
+ * until the stage it was opened in ends.
60
+ * @param {string} name
61
+ * @returns {() => void}
62
+ */
63
+ start(name) {
64
+ return begin(name);
65
+ },
54
66
  /** @param {string} name */
55
67
  hit(name) {
56
68
  hits.set(name, (hits.get(name) ?? 0) + 1);
@@ -180,11 +180,11 @@ function columnsMarkdown(block, lines, step) {
180
180
  }
181
181
 
182
182
  import MarkdownIt from "markdown-it";
183
+ import { blockTokens } from "./markdown-blocks.mjs";
183
184
  import { slugify } from "./content-slug.mjs";
184
185
  import { spanMarkdownPlugin } from "./content-spans.mjs";
185
186
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
186
187
 
187
- const structureParser = new MarkdownIt({ html: true });
188
188
  const lineParser = new MarkdownIt({ html: true }).use(spanMarkdownPlugin);
189
189
 
190
190
  /** The structural first span of a line, with its source ranges and keys. */
@@ -223,7 +223,7 @@ export function scanVerse(source) {
223
223
  blocks = [],
224
224
  errors = [];
225
225
  const literalLines = new Set();
226
- for (const token of structureParser.parse(String(source ?? ""), {})) {
226
+ for (const token of blockTokens(source)) {
227
227
  if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
228
228
  for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
229
229
  }
@@ -464,6 +464,16 @@ export function dictionaryAndTermRows(records, all = records) {
464
464
  );
465
465
  return hit ? hit.entry[key] : undefined;
466
466
  };
467
+ // One piece lookup per language, shared by every word that language states.
468
+ const glossers = new Map();
469
+ const glossesFor = (language) => {
470
+ if (!glossers.has(language))
471
+ glossers.set(
472
+ language,
473
+ pieceGlosses(...lineage(language).map((key) => languages.get(key).data)),
474
+ );
475
+ return glossers.get(language);
476
+ };
467
477
  const etymologyOf = ({ word, statedIn, depth }) => {
468
478
  const blank = Object.fromEntries(ETYMOLOGY_KEYS.map((key) => [key, null]));
469
479
  if (depth > 0)
@@ -476,13 +486,15 @@ export function dictionaryAndTermRows(records, all = records) {
476
486
  };
477
487
  const source = isMap(word.source) ? word.source : {};
478
488
  const built = Array.isArray(word.built) ? word.built.map(String) : [];
479
- const glossOf = pieceGlosses(...lineage(statedIn).map((key) => languages.get(key).data));
480
489
  const out = {
481
490
  ...blank,
482
491
  kind: typeof source.kind === "string" ? source.kind : null,
483
492
  language: typeof source.language === "string" ? source.language : null,
484
493
  form: typeof source.form === "string" ? source.form : null,
485
- parts: built.length ? built.map((form) => ({ form, gloss: glossOf(form) })) : null,
494
+ parts:
495
+ built.length ?
496
+ built.map((form) => ({ form, gloss: glossesFor(statedIn)(form) }))
497
+ : null,
486
498
  root: typeof word.root === "string" ? word.root : null,
487
499
  frame: typeof word.frame === "string" ? word.frame : null,
488
500
  };
@@ -69,7 +69,14 @@ function capitalWriting(enc, word, native, font) {
69
69
  upper.to + native.slice(lower.to.length)
70
70
  : undefined;
71
71
  }
72
+ /** Each lexicon's forms, found once; a lexicon is not modified once it is converted with. */
73
+ const formsOf = new WeakMap();
72
74
  function entryForms(lexicon) {
75
+ if (!lexicon) return findEntryForms(lexicon);
76
+ if (!formsOf.has(lexicon)) formsOf.set(lexicon, findEntryForms(lexicon));
77
+ return formsOf.get(lexicon);
78
+ }
79
+ function findEntryForms(lexicon) {
73
80
  const data = lexicon?.data ?? {};
74
81
  const derived = new Map();
75
82
  for (const entry of derivedForms(data)) {
@@ -122,6 +129,44 @@ function entryForms(lexicon) {
122
129
  ];
123
130
  }
124
131
 
132
+ /** Each lexicon's phrases of more than one word, with the patterns that find them. */
133
+ const phrasesFound = new WeakMap();
134
+ function phrasesOf(lexicon) {
135
+ if (!phrasesFound.has(lexicon))
136
+ phrasesFound.set(
137
+ lexicon,
138
+ entryForms(lexicon)
139
+ .map((entry) => entry.form)
140
+ .filter((form) => [...form.matchAll(WORD)].length > 1)
141
+ .map((form) => ({
142
+ form,
143
+ spaces: form.match(/[\p{Zs}\u0009-\u000D]+/gu) ?? [],
144
+ pattern: new RegExp(
145
+ form
146
+ .split(/[\p{Zs}\u0009-\u000D]+/u)
147
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
148
+ .join("[\\p{Zs}\\u0009-\\u000D]+"),
149
+ "iuy",
150
+ ),
151
+ })),
152
+ );
153
+ return phrasesFound.get(lexicon);
154
+ }
155
+
156
+ /** Each lexicon's words' forms and variants, as written. */
157
+ const statedFound = new WeakMap();
158
+ function statedForms(lexicon) {
159
+ if (!statedFound.has(lexicon)) {
160
+ const forms = new Set();
161
+ for (const entry of list(lexicon.data.words)) {
162
+ forms.add(entry.form);
163
+ for (const variant of list(entry.variants)) forms.add(variant);
164
+ }
165
+ statedFound.set(lexicon, forms);
166
+ }
167
+ return statedFound.get(lexicon);
168
+ }
169
+
125
170
  function literalEnd(text, at) {
126
171
  let cursor = at + OPEN.length;
127
172
  while (cursor < text.length) {
@@ -164,23 +209,7 @@ export function encodeWithLanguage(enc, text, options = {}) {
164
209
  }
165
210
  const writing =
166
211
  options.lexicon ? writingsOf(options.lexicon.data, enc.name, options.font) : undefined;
167
- const phrases =
168
- options.lexicon ?
169
- entryForms(options.lexicon)
170
- .map((entry) => entry.form)
171
- .filter((form) => [...form.matchAll(WORD)].length > 1)
172
- .map((form) => ({
173
- form,
174
- spaces: form.match(/[\p{Zs}\u0009-\u000D]+/gu) ?? [],
175
- pattern: new RegExp(
176
- form
177
- .split(/[\p{Zs}\u0009-\u000D]+/u)
178
- .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
179
- .join("[\\p{Zs}\\u0009-\\u000D]+"),
180
- "iuy",
181
- ),
182
- }))
183
- : [];
212
+ const phrases = options.lexicon ? phrasesOf(options.lexicon) : [];
184
213
  let at = 0;
185
214
  const appendRows = (from, to) => {
186
215
  let cursor = from;
@@ -267,9 +296,7 @@ export function encodeWithLanguage(enc, text, options = {}) {
267
296
  } else if (own && "text" in own) {
268
297
  appendRows(at, start);
269
298
  let native = own.text;
270
- const exact = list(options.lexicon.data.words).some(
271
- (entry) => entry.form === lookup || list(entry.variants).includes(lookup),
272
- );
299
+ const exact = statedForms(options.lexicon).has(lookup);
273
300
  if (!exact && match[0] !== match[0].toLowerCase()) {
274
301
  const capital =
275
302
  match[0] === initialCapital(match[0].toLowerCase()) ?