@heroiclands/package-build 23.7.1 → 23.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +32 -0
  3. package/bin/content-commands.mjs +20 -6
  4. package/ci/check-banners.mjs +37 -19
  5. package/container.mjs +48 -10
  6. package/docs/api.md +87 -43
  7. package/docs/authoring/frontmatter.md +30 -1
  8. package/docs/authoring/links-and-markup.md +14 -0
  9. package/docs/commands.md +12 -2
  10. package/docs/content-format.md +23 -0
  11. package/docs/design.md +31 -0
  12. package/docs/guides/e2e.md +37 -1
  13. package/docs/project-setup.md +30 -15
  14. package/docs/reference/format-details.md +44 -9
  15. package/docs/reference/note-types.md +7 -4
  16. package/e2e.mjs +89 -2
  17. package/engine/base-compiler.mjs +11 -18
  18. package/engine/compile-corpus.mjs +2 -1
  19. package/engine/content-alerts.mjs +4 -2
  20. package/engine/content-blocks.mjs +18 -36
  21. package/engine/content-index.mjs +35 -22
  22. package/engine/content-poetry.mjs +9 -0
  23. package/engine/diagnostics.mjs +24 -11
  24. package/engine/frontmatter-lint.mjs +1 -1
  25. package/engine/generate.mjs +12 -22
  26. package/engine/helpers.mjs +8 -6
  27. package/engine/infobox.mjs +3 -0
  28. package/engine/journals.mjs +11 -1
  29. package/engine/markdownlint-run.mjs +12 -2
  30. package/engine/markup-validation.mjs +34 -0
  31. package/engine/note-addresses.mjs +1 -0
  32. package/engine/note-claims.mjs +19 -10
  33. package/engine/note-package.mjs +3 -5
  34. package/engine/note-vocabulary.mjs +29 -1
  35. package/engine/pdf-build.mjs +8 -1
  36. package/engine/prose-lint.mjs +12 -6
  37. package/engine/related-pages.mjs +22 -3
  38. package/engine/see-also.mjs +150 -0
  39. package/engine/site-build.mjs +5 -1
  40. package/engine/site-preview.mjs +4 -2
  41. package/engine/sql-tables.mjs +25 -10
  42. package/engine/standings.mjs +14 -12
  43. package/hugo-theme/layouts/partials/related.html +25 -8
  44. package/package.json +2 -1
  45. package/types/container.d.mts +4 -3
  46. package/types/e2e.d.mts +28 -0
  47. package/types/engine/base-compiler.d.mts +2 -2
  48. package/types/engine/compile-corpus.d.mts +2 -1
  49. package/types/engine/content-index.d.mts +20 -7
  50. package/types/engine/diagnostics.d.mts +7 -6
  51. package/types/engine/helpers.d.mts +7 -4
  52. package/types/engine/markdownlint-run.d.mts +3 -1
  53. package/types/engine/markup-validation.d.mts +14 -0
  54. package/types/engine/note-addresses.d.mts +10 -0
  55. package/types/engine/note-claims.d.mts +7 -1
  56. package/types/engine/note-package.d.mts +3 -1
  57. package/types/engine/note-vocabulary.d.mts +1 -0
  58. package/types/engine/related-pages.d.mts +10 -1
  59. package/types/engine/see-also.d.mts +55 -0
  60. package/types/engine/sql-tables.d.mts +7 -3
@@ -764,6 +764,38 @@ declares the key. Any other art key authored where no pass emits it, such as a
764
764
  the note still compiles correctly and a site template may read there what no
765
765
  document carries. `null` is never reported.
766
766
 
767
+ #### Deliberate cross-references
768
+
769
+ Every note type accepts `data.seeAlso`: an ordered list of maps with a required
770
+ `address` and an optional string `gloss`. The Address names a whole note,
771
+ defaults to the current package and the `note` system, and has no default type.
772
+ It may name a note in a declared dependency. A gloss is a short plain-text
773
+ phrase explaining the connection.
774
+
775
+ ```yaml
776
+ data:
777
+ seeAlso:
778
+ - address: lore-harborhistory
779
+ gloss: The founding of the port
780
+ - address: place-breakwater
781
+ - address: thalorna-note-lore-searoutes
782
+ gloss: Routes beyond the bay
783
+ ```
784
+
785
+ The vocabulary declares the closed entry shape. The note boundary decodes each
786
+ nested Address, and the frontmatter check locates unresolved, duplicate and
787
+ self references at `data.seeAlso.<index>.address`. Duplicate detection compares
788
+ resolved destinations, so alternative spellings of one target do not make two
789
+ entries. Anchors are not accepted.
790
+
791
+ On the website, `related.seeAlso` carries `{ title, url, type, gloss? }` entries
792
+ in authored order. The theme renders that group ahead of the inferred
793
+ backlinks and mentions, with duplicates removed by resolved URL. Deliberate
794
+ references can point outside the local site to declared dependency pages.
795
+ The field has no infobox row. Foundry journals append a See also page with
796
+ compendium links; books append a See also section with the book renderer's
797
+ normal internal or external links. An absent or empty list emits nothing.
798
+
767
799
  #### The art slots
768
800
 
769
801
  A note declares the art its document needs, and every other image in it is
@@ -821,15 +853,18 @@ are, so a key legal everywhere is stated once here rather than repeated in
821
853
  twenty-five tables where the one that was mistyped would be the one nobody
822
854
  noticed:
823
855
 
824
- | shared `data` property | Values | Description |
825
- | ---------------------- | --------------------- | ------------------------------------------------------------ |
826
- | `id` | `string` | A pinned document identity. |
827
- | `pack` | `string` | The shared compendium route. |
828
- | `packFolder` | `Address` or pack map | The shared compendium folder Address. |
829
- | `harnworld` | `{ realm?, ritual? }` | HârnWorld source details. |
830
- | `harnworld.realm` | `string` | The HârnWorld realm the subject belongs to. |
831
- | `harnworld.ritual` | `string[]` | The HârnWorld religions the subject observes. |
832
- | `banner` | `Address` | The page's hero image — an `image` address, cut to 1792×768. |
856
+ | shared `data` property | Values | Description |
857
+ | ---------------------- | ----------------------------- | ------------------------------------------------------------------------------------------ |
858
+ | `seeAlso` | list of `{ address, gloss? }` | Deliberate cross-references in reading order, shown on the site, in journals and in books. |
859
+ | `seeAlso[].address` | a note `Address` | Required; the whole note to read next. |
860
+ | `seeAlso[].gloss` | `string` | An optional short phrase saying why to follow the reference. |
861
+ | `id` | `string` | A pinned document identity. |
862
+ | `pack` | `string` | The shared compendium route. |
863
+ | `packFolder` | `Address` or pack map | The shared compendium folder Address. |
864
+ | `harnworld` | `{ realm?, ritual? }` | HârnWorld source details. |
865
+ | `harnworld.realm` | `string` | The HârnWorld realm the subject belongs to. |
866
+ | `harnworld.ritual` | `string[]` | The HârnWorld religions the subject observes. |
867
+ | `banner` | `Address` | The page's hero image — an `image` address, cut to 1792×768. |
833
868
 
834
869
  `data.harnworld` takes two keys and nothing else: `realm`, a string naming the
835
870
  HârnWorld realm the subject belongs to, and `ritual`, a list of strings naming
@@ -34,6 +34,7 @@ These fields are accepted by every note type. A field's value can still be irrel
34
34
 
35
35
  | Field | Shape | Meaning |
36
36
  | ----------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
37
+ | `data.seeAlso` | list of `{ address, gloss? }` | Deliberate cross-references in reading order, with optional glosses; shown on the site, in journals and in books. |
37
38
  | `data.id` | string | A fixed document identity when the derived identity is unsuitable. |
38
39
  | `data.pack` | string | The shared compendium route, with a system block overriding it. |
39
40
  | `data.packFolder` | an Address, or a map of Addresses keyed by pack | The shared compendium folder Address, with a system block overriding it. |
@@ -42,10 +43,12 @@ These fields are accepted by every note type. A field's value can still be irrel
42
43
 
43
44
  **Inner keys** — a key a field's value does not declare is an error at its own line and column.
44
45
 
45
- | Key | Shape | Required | Meaning |
46
- | ----------------------- | --------------- | -------- | --------------------------------------------- |
47
- | `data.harnworld.realm` | a string | no | The HârnWorld realm the subject belongs to. |
48
- | `data.harnworld.ritual` | list of strings | no | The HârnWorld religions the subject observes. |
46
+ | Key | Shape | Required | Meaning |
47
+ | ------------------------ | --------------- | -------- | -------------------------------------------------- |
48
+ | `data.seeAlso[].address` | a note Address | yes | The note to read next. |
49
+ | `data.seeAlso[].gloss` | a string | no | A short phrase saying why to follow the reference. |
50
+ | `data.harnworld.realm` | a string | no | The HârnWorld realm the subject belongs to. |
51
+ | `data.harnworld.ritual` | list of strings | no | The HârnWorld religions the subject observes. |
49
52
 
50
53
  ## Type-specific fields
51
54
 
package/e2e.mjs CHANGED
@@ -43,6 +43,8 @@ import crypto from "node:crypto";
43
43
  import { readdirSync, statSync } from "node:fs";
44
44
  import fs from "node:fs/promises";
45
45
  import path from "node:path";
46
+ import os from "node:os";
47
+ import { Browser, BrowserPlatform, computeExecutablePath, install } from "@puppeteer/browsers";
46
48
  import process from "node:process";
47
49
  import { spawnSync } from "node:child_process";
48
50
 
@@ -796,6 +798,91 @@ export function suiteExecutables(command) {
796
798
  return [program];
797
799
  }
798
800
 
801
+ /** The Chrome for Testing build shared by local and CI Cypress runs. */
802
+ export const CHROME_BUILD_ID = "155.0.8059.84";
803
+
804
+ /**
805
+ * The browser cache survives reseeding and container teardown.
806
+ * @param {NodeJS.ProcessEnv} [env] - Environment overrides.
807
+ * @returns {string} The browser cache directory.
808
+ */
809
+ export function browserCacheDir(env = process.env) {
810
+ if (env.HL_BROWSER_CACHE) return path.resolve(env.HL_BROWSER_CACHE);
811
+ if (env.FOUNDRYVTT_CACHE) return path.resolve(env.FOUNDRYVTT_CACHE, "..", "browser-cache");
812
+ if (env.HL_E2E_ROOT) return path.resolve(env.HL_E2E_ROOT, "browser-cache");
813
+ const cache =
814
+ env.XDG_CACHE_HOME ||
815
+ (process.platform === "darwin" ?
816
+ path.join(os.homedir(), "Library", "Caches")
817
+ : path.join(os.homedir(), ".cache"));
818
+ return path.join(cache, "heroiclands", "chrome-for-testing");
819
+ }
820
+
821
+ /**
822
+ * Supply pinned Chrome only to a recognizable Cypress command without a browser.
823
+ * Explicit browser arguments remain verbatim, including Electron and paths.
824
+ * @param {object} opts
825
+ * @param {readonly string[]} opts.command - The configured suite command.
826
+ * @param {string[]} [opts.args] - Extra suite arguments.
827
+ * @param {NodeJS.ProcessEnv} [opts.env] - Cache overrides.
828
+ * @param {string} [opts.platform] - Host operating system.
829
+ * @param {string} [opts.arch] - Host architecture.
830
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
831
+ * @returns {Promise<string[]>} Arguments to append to the suite command.
832
+ */
833
+ export async function prepareSuiteBrowser({
834
+ command,
835
+ args = [],
836
+ env = process.env,
837
+ platform = process.platform,
838
+ arch = process.arch,
839
+ log = () => {},
840
+ }) {
841
+ const tool = suiteExecutables(command).at(-1);
842
+ if (!tool || executableName(tool) !== "cypress") return args;
843
+ const all = [...command, ...args];
844
+ const explicit = all.findLastIndex(
845
+ (arg) => arg === "--browser" || arg.startsWith("--browser="),
846
+ );
847
+ if (explicit !== -1) {
848
+ const selected =
849
+ all[explicit] === "--browser" ?
850
+ all[explicit + 1]
851
+ : all[explicit].slice("--browser=".length);
852
+ log(
853
+ `▸ Cypress browser: ${selected ?? "(missing --browser value)"} (explicit; version reported by Cypress)`,
854
+ );
855
+ return args;
856
+ }
857
+ const browserPlatform =
858
+ platform === "darwin" && arch === "arm64" ? BrowserPlatform.MAC_ARM
859
+ : platform === "linux" && arch === "x64" ? BrowserPlatform.LINUX
860
+ : null;
861
+ if (!browserPlatform)
862
+ throw new Error(
863
+ `Chrome for Testing has no configured build for ${platform}/${arch}. Pass --browser <name-or-path> to select an installed browser.`,
864
+ );
865
+ const options = {
866
+ browser: Browser.CHROME,
867
+ buildId: CHROME_BUILD_ID,
868
+ platform: browserPlatform,
869
+ cacheDir: browserCacheDir(env),
870
+ };
871
+ let executablePath = computeExecutablePath(options);
872
+ if (!isExecutableFile(executablePath)) {
873
+ try {
874
+ executablePath = (await install(options)).executablePath;
875
+ } catch (cause) {
876
+ throw new Error(
877
+ `Cannot install Chrome for Testing ${CHROME_BUILD_ID} for ${platform}/${arch} in ${options.cacheDir}. Check network access or pass --browser <name-or-path>.`,
878
+ { cause },
879
+ );
880
+ }
881
+ }
882
+ log(`▸ Cypress browser: Chrome for Testing ${CHROME_BUILD_ID} (${executablePath})`);
883
+ return [...args, "--browser", executablePath];
884
+ }
885
+
799
886
  /**
800
887
  * @param {string} candidate - An absolute path.
801
888
  * @returns {boolean} Whether it is there and is a file.
@@ -1128,7 +1215,7 @@ export async function e2eRun({
1128
1215
  });
1129
1216
  return runSuite({
1130
1217
  command,
1131
- args: suiteArgs,
1218
+ args: await prepareSuiteBrowser({ command, args: suiteArgs, env, log }),
1132
1219
  cwd: config.rootDir,
1133
1220
  // Only a headless run makes a claim worth checking. `open` hands
1134
1221
  // the runner to a person, who decides what to execute and when to
@@ -1211,7 +1298,7 @@ export async function e2eFast({ config, argv = [], env = process.env, log = () =
1211
1298
  }
1212
1299
  return runSuite({
1213
1300
  command,
1214
- args: suiteArgs,
1301
+ args: await prepareSuiteBrowser({ command, args: suiteArgs, env, log }),
1215
1302
  cwd: config.rootDir,
1216
1303
  results: config.e2eResults,
1217
1304
  env,
@@ -1254,19 +1254,18 @@ export class BasePackCompiler {
1254
1254
  }
1255
1255
  }
1256
1256
 
1257
- // The corpus, from the index this compile derived once — not a walk of
1258
- // this pass's own. Each note is then read for its **prose**: the
1259
- // index carries what is *about* a note and deliberately not its text,
1260
- // nor the `bodyLine`/`bodyColumn` a diagnostic needs, and this pass has
1261
- // to have the body anyway. So the read is one this pass makes anyway;
1262
- // the index decides which files it covers.
1257
+ // Parsed source and body coordinates belong to the shared corpus.
1258
+ // A directly supplied corpus can fill missing entries on first use.
1259
+ this.corpus.parsed ??= new Map();
1263
1260
  const dates = reckoningContext({ notes: this.corpus.records });
1264
1261
  /** The note first filed under each document id in this pass. @type {Map<string, string>} */
1265
1262
  const idOwners = new Map();
1266
1263
  for (const record of this.corpus.records) {
1267
1264
  if (!isNoteRecord(record)) continue;
1268
1265
  const absPath = noteFile(this.contentBase, record);
1269
- const { body, bodyLine, bodyColumn } = parseMarkdownFile(absPath);
1266
+ const parsed = this.corpus.parsed.get(absPath) ?? parseMarkdownFile(absPath);
1267
+ this.corpus.parsed.set(absPath, parsed);
1268
+ const { body, bodyLine, bodyColumn, raw } = parsed;
1270
1269
  const fm = cloneAddressState(authoredFrontmatter(record));
1271
1270
  // Which note this pass is on, so anything it calls can report a
1272
1271
  // position without every method having to be handed one.
@@ -1284,7 +1283,7 @@ export class BasePackCompiler {
1284
1283
  // comes from the diagnostic locator, so the message must not
1285
1284
  // repeat it.
1286
1285
  try {
1287
- assertNoDeclaredPackage(fm, { absPath });
1286
+ assertNoDeclaredPackage(fm, { absPath, raw });
1288
1287
  } catch (err) {
1289
1288
  stats.declined++;
1290
1289
  this.errorCount++;
@@ -1375,7 +1374,7 @@ export class BasePackCompiler {
1375
1374
  `${path.relative(process.cwd(), owner)}. Give one of the two notes a ` +
1376
1375
  `different \`id\` under \`data:\`, or remove it to take the id derived ` +
1377
1376
  `from its address`,
1378
- locateFrontmatterKey(absPath, "id"),
1377
+ locateFrontmatterKey(absPath, "id", undefined, { raw }),
1379
1378
  );
1380
1379
  continue;
1381
1380
  }
@@ -1398,15 +1397,9 @@ export class BasePackCompiler {
1398
1397
  );
1399
1398
  const position =
1400
1399
  err.position ??
1401
- (err.keyPath ?
1402
- positionOfFrontmatterPath(fs.readFileSync(absPath, "utf8"), err.keyPath)
1403
- : repeatedAnchor ?
1404
- positionOfLiteral(
1405
- fs.readFileSync(absPath, "utf8"),
1406
- `{#${repeatedAnchor[1]}}`,
1407
- 2,
1408
- )
1409
- : undefined);
1400
+ (err.keyPath ? positionOfFrontmatterPath(raw, err.keyPath)
1401
+ : repeatedAnchor ? positionOfLiteral(raw, `{#${repeatedAnchor[1]}}`, 2)
1402
+ : undefined);
1410
1403
  // `position` is set by whatever failed if it knew where — an
1411
1404
  // unresolved address, a bad table directive — so the report
1412
1405
  // points at the line rather than at the note.
@@ -60,7 +60,7 @@ import { prepareTreeSqlTables } from "./sql-tables.mjs";
60
60
  * created when none is passed, and returned on the corpus either way — a note
61
61
  * the index refuses is a note the compile must still *report*, exactly as the
62
62
  * compile loop reported it when the loop was the first to see it.
63
- * @returns {Promise<{records: object[], linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[]}>} The corpus,
63
+ * @returns {Promise<{records: object[], parsed: Map<string, object>, linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[]}>} The corpus,
64
64
  * its indexes, and the notes it could not record.
65
65
  */
66
66
  export async function buildCompileCorpus({
@@ -86,6 +86,7 @@ export async function buildCompileCorpus({
86
86
  const sqlOptions = { ...corpusOptions, skipDirectories, parsed };
87
87
  return {
88
88
  records,
89
+ parsed,
89
90
  problems: collected,
90
91
  linkIndex: buildContentLinkIndex(contentBase, router, corpusOptions),
91
92
  sqlTables: await prepareTreeSqlTables(contentBase, sqlOptions),
@@ -48,7 +48,7 @@ export const ALERT_TYPES = Object.freeze({
48
48
  ),
49
49
  },
50
50
  });
51
- const parser = new MarkdownIt();
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.
@@ -85,7 +85,9 @@ export function scanAlerts(source) {
85
85
  if (match[1] !== match[1].toUpperCase() || !Object.hasOwn(ALERT_TYPES, type)) {
86
86
  errors.push({
87
87
  ...at,
88
- message: `unknown alert type ${match[1]}; use NOTE, TIP, IMPORTANT or WARNING`,
88
+ message: `unknown alert type ${match[1]}; use ${Object.keys(ALERT_TYPES)
89
+ .map((name) => name.toUpperCase())
90
+ .join(", ")}`,
89
91
  });
90
92
  continue;
91
93
  }
@@ -62,6 +62,7 @@ import { WITHHELD_CLASS, parseHeadingLine, withheldSections } from "./heading-at
62
62
  * and a stray `<section>` would close the block early.
63
63
  */
64
64
  const titleParser = new MarkdownIt({ html: false });
65
+ const structureParser = new MarkdownIt({ html: true });
65
66
 
66
67
  /** The blocks an author may open, with the heading each takes by default. */
67
68
  export const BLOCK_NAMES = Object.freeze({
@@ -99,28 +100,24 @@ function escapeAttribute(value) {
99
100
  * @returns {{blocks: Array<{name: string, title: string, id: string, classes: string[], attributes: Record<string, string>, start: number, end: number, body: string}>, errors: Array<{line: number, column: number, message: string}>}}
100
101
  */
101
102
  export function scanBlocks(source) {
102
- const lines = String(source ?? "").split("\n"),
103
+ const lines = String(source ?? "").split(/\r?\n/),
103
104
  blocks = [],
104
105
  errors = [];
105
106
  const stack = [];
106
- let fence = null;
107
+ // A bare fence can belong to a quote through lazy continuation.
108
+ // Parser ranges decide whether it can delimit a container.
109
+ const literalLines = new Set();
110
+ for (const token of structureParser.parse(String(source ?? ""), {})) {
111
+ if (
112
+ !token.map ||
113
+ !["blockquote_open", "fence", "code_block", "html_block"].includes(token.type)
114
+ )
115
+ continue;
116
+ for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
117
+ }
107
118
  for (let i = 0; i < lines.length; i++) {
119
+ if (literalLines.has(i)) continue;
108
120
  const line = lines[i];
109
- const code = /^ *(`{3,}|~{3,})(.*)$/.exec(line);
110
- if (fence) {
111
- if (
112
- code &&
113
- code[1][0] === fence[0] &&
114
- code[1].length >= fence.length &&
115
- !code[2].trim()
116
- )
117
- fence = null;
118
- continue;
119
- }
120
- if (code) {
121
- fence = code[1];
122
- continue;
123
- }
124
121
  const close = /^ *:::\s*$/.test(line);
125
122
  const named = /^ *:::([A-Za-z][A-Za-z0-9-]*)(?:\s+(.*))?$/.exec(line);
126
123
  const div = /^ *:::\s+(\{.*)$/.exec(line);
@@ -135,7 +132,7 @@ export function scanBlocks(source) {
135
132
  if (!block.body)
136
133
  errors.push({
137
134
  line: block.start + 1,
138
- column: 1,
135
+ column: lines[block.start].indexOf(":::") + 1,
139
136
  message: `${block.name} block is empty`,
140
137
  });
141
138
  if (!stack.length) blocks.push(block);
@@ -144,7 +141,7 @@ export function scanBlocks(source) {
144
141
  if (!close && !named && !div) continue;
145
142
  const name = named?.[1] ?? "div",
146
143
  raw = named?.[2] ?? div?.[1] ?? "";
147
- const at = { line: i + 1, column: 1 };
144
+ const at = { line: i + 1, column: line.indexOf(":::") + 1 };
148
145
  if (name !== "div" && !Object.hasOwn(BLOCK_NAMES, name)) {
149
146
  errors.push({ ...at, message: `there is no ${name} block; the blocks are ${names()}` });
150
147
  stack.push({ start: i, rejected: true });
@@ -189,27 +186,12 @@ export function scanBlocks(source) {
189
186
  if (!block.rejected)
190
187
  errors.push({
191
188
  line: block.start + 1,
192
- column: 1,
189
+ column: lines[block.start].indexOf(":::") + 1,
193
190
  message: `${block.name} block needs a closing ::: line`,
194
191
  });
195
192
  for (const block of blocks) {
196
- let literal = null;
197
193
  for (let i = block.start + 1; i < block.end; i++) {
198
- const marker = /^ *(`{3,}|~{3,})(.*)$/.exec(lines[i]);
199
- if (literal) {
200
- if (
201
- marker &&
202
- marker[1][0] === literal[0] &&
203
- marker[1].length >= literal.length &&
204
- !marker[2].trim()
205
- )
206
- literal = null;
207
- continue;
208
- }
209
- if (marker) {
210
- literal = marker[1];
211
- continue;
212
- }
194
+ if (literalLines.has(i)) continue;
213
195
  if (parseHeadingLine(lines[i])?.startsPage)
214
196
  errors.push({
215
197
  line: i + 1,
@@ -410,23 +410,25 @@ function foundryBlock(entry, system) {
410
410
  * @param {string} [absPath] - The file, read only on the failing path to locate
411
411
  * the offending key.
412
412
  * @param {string} [contentPackage] - The package this tree compiles as.
413
+ * @param {string} [raw] - Source text already read by the caller.
413
414
  * @returns {void}
414
415
  * @throws {Error} When the note authors a derived key. `file` and `position`
415
416
  * ride on the error.
416
417
  */
417
- function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
418
+ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw) {
418
419
  for (const key of DERIVED_KEYS) {
419
420
  if (!Object.hasOwn(frontmatter ?? {}, key)) continue;
420
421
  if (key === "package") {
421
422
  // Throws with its own wording, and its own position.
422
- assertNoDeclaredPackage(frontmatter, { absPath, configured: contentPackage });
423
+ assertNoDeclaredPackage(frontmatter, { absPath, configured: contentPackage, raw });
423
424
  }
424
425
  const err = new Error(
425
426
  `\`${key}:\` is derived by the content index and cannot be ` +
426
427
  `authored — rename the frontmatter field`,
427
428
  );
428
429
  err.file = relPath;
429
- const position = absPath ? locateFrontmatterKey(absPath, key) : undefined;
430
+ const position =
431
+ absPath ? locateFrontmatterKey(absPath, key, undefined, { raw }) : undefined;
430
432
  if (position) err.position = position;
431
433
  throw err;
432
434
  }
@@ -445,6 +447,7 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
445
447
  * @param {string} options.relPath - Its path below the content root, POSIX-separated.
446
448
  * @param {string} [options.absPath] - The file, read only on the failing path to
447
449
  * locate the offending key.
450
+ * @param {string} [options.raw] - Source text already read by the caller.
448
451
  * @param {string} options.contentPackage - The package the tree compiles as.
449
452
  * @param {string} [options.body] - The note's markdown body, for its anchors
450
453
  * and for the one question that decides whether the note publishes a page. A
@@ -473,6 +476,7 @@ export function buildIndexRecord({
473
476
  frontmatter,
474
477
  relPath,
475
478
  absPath,
479
+ raw,
476
480
  contentPackage,
477
481
  body,
478
482
  bodyLine,
@@ -481,7 +485,7 @@ export function buildIndexRecord({
481
485
  dateContext,
482
486
  resolveRole,
483
487
  }) {
484
- assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
488
+ assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw);
485
489
 
486
490
  const posix = relPath.split(path.sep).join("/");
487
491
  const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
@@ -490,9 +494,7 @@ export function buildIndexRecord({
490
494
  } catch (error) {
491
495
  error.file = absPath ?? relPath;
492
496
  if (absPath && error.keyPath) {
493
- const yamlText = fs
494
- .readFileSync(absPath, "utf8")
495
- .match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
497
+ const yamlText = (raw ?? rawNote(absPath)).match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
496
498
  const locate = (/** @type {any} */ found) => {
497
499
  const position = positionOfYamlPath(yamlText, found.keyPath, {
498
500
  key: found.addressKey,
@@ -540,7 +542,7 @@ export function buildIndexRecord({
540
542
  stub ? null : (
541
543
  [
542
544
  ...collectAnchors(body, bodyLine, resolveRole),
543
- ...eventAnchors(frontmatter, rawNote(absPath)),
545
+ ...eventAnchors(frontmatter, raw ?? rawNote(absPath)),
544
546
  ]
545
547
  .sort((a, b) => (a.line ?? 0) - (b.line ?? 0))
546
548
  .map((a) => ({
@@ -659,6 +661,7 @@ export function indexRecordsForNote({
659
661
  bodyLine,
660
662
  relPath,
661
663
  absPath,
664
+ raw,
662
665
  contentPackage,
663
666
  manifest,
664
667
  addressContext,
@@ -672,6 +675,7 @@ export function indexRecordsForNote({
672
675
  bodyLine,
673
676
  relPath,
674
677
  absPath,
678
+ raw,
675
679
  contentPackage,
676
680
  manifest,
677
681
  addressContext,
@@ -783,10 +787,14 @@ function cultureTuple(value) {
783
787
  * immediate parent whose package is not this one.
784
788
  * @param {object[]} [options.problems] - Receives each conflict as a finding.
785
789
  * Omitted, every conflict is collected and one Error lists them all.
790
+ * @param {Map<string, {raw: string}>} [options.parsed] - Notes already read, keyed by file.
786
791
  * @returns {Array<Record<string, any>>} The same records.
787
792
  * @throws {Error} When `problems` is omitted and any note's sources disagree.
788
793
  */
789
- export function resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems }) {
794
+ export function resolveCultures(
795
+ records,
796
+ { contentPackage, contentBase, foreignRecord, problems, parsed },
797
+ ) {
790
798
  // Each note under every key a link may name it by: its record's canonical
791
799
  // address, and its page address in the `note` system, which is what a
792
800
  // `data.parents`, `data.seat` or `data.government` link names.
@@ -879,7 +887,7 @@ export function resolveCultures(records, { contentPackage, contentBase, foreignR
879
887
  */
880
888
  const conflict = (record, keyPath, subject, distinct) => {
881
889
  const file = contentBase ? noteFile(contentBase, record) : record.file?.path;
882
- const raw = contentBase && fs.existsSync(file) ? fs.readFileSync(file, "utf8") : undefined;
890
+ const raw = parsed?.get(file)?.raw ?? (contentBase ? rawNote(file) : undefined);
883
891
  let position = positionOfFrontmatterPath(raw, keyPath, { key: true });
884
892
  if (position.line === undefined && keyPath.length > 1)
885
893
  position = positionOfFrontmatterPath(raw, ["data"], { key: true });
@@ -1026,9 +1034,9 @@ export function resolveCultures(records, { contentPackage, contentBase, foreignR
1026
1034
  * @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
1027
1035
  * Another package's entry for a canonical address, read for a parent that
1028
1036
  * package publishes; see {@link resolveCultures}.
1029
- * @param {Map<string, {body: string, frontmatter: object|null}>} [options.parsed] -
1030
- * Filled with each note's body and its frontmatter as authored, keyed by the
1031
- * note's file, so a later pass reads the notes this walk already parsed.
1037
+ * @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [options.parsed] -
1038
+ * Filled with each note's source text, body coordinates and frontmatter as
1039
+ * authored, keyed by file, for reuse throughout the command.
1032
1040
  * @returns {Array<Record<string, any>>} The records, in a total order that does
1033
1041
  * not depend on directory-read order.
1034
1042
  */
@@ -1042,7 +1050,7 @@ export function collectContentIndex(
1042
1050
  problems,
1043
1051
  addressContext,
1044
1052
  foreignRecord,
1045
- parsed,
1053
+ parsed = new Map(),
1046
1054
  },
1047
1055
  ) {
1048
1056
  const records = [];
@@ -1115,10 +1123,16 @@ export function collectContentIndex(
1115
1123
 
1116
1124
  // Each recorded note with its body, for the words its prose cites.
1117
1125
  const bodies = [];
1118
- for (const { frontmatter, body, bodyLine, absPath } of parsedNotes) {
1126
+ for (const { frontmatter, body, bodyLine, bodyColumn, raw, absPath } of parsedNotes) {
1119
1127
  // Before the index derives anything onto the frontmatter, so a later
1120
1128
  // pass reads the note as authored.
1121
- parsed?.set(absPath, { body, frontmatter: structuredClone(frontmatter) });
1129
+ parsed.set(absPath, {
1130
+ body,
1131
+ bodyLine,
1132
+ bodyColumn,
1133
+ raw,
1134
+ frontmatter: structuredClone(frontmatter),
1135
+ });
1122
1136
  const fm = frontmatter ?? {};
1123
1137
  applyComputedBeingAge(fm, present, dates);
1124
1138
  const relPath = path.relative(contentBase, absPath);
@@ -1127,6 +1141,7 @@ export function collectContentIndex(
1127
1141
  frontmatter: fm,
1128
1142
  relPath,
1129
1143
  absPath,
1144
+ raw,
1130
1145
  contentPackage,
1131
1146
  body,
1132
1147
  bodyLine,
@@ -1142,9 +1157,7 @@ export function collectContentIndex(
1142
1157
  err.identity = { type: fm.type, shortcode: fm.shortcode };
1143
1158
  err.keyPath = ["shortcode"];
1144
1159
  err.message = `shortcode "${fm.shortcode}" is not strictly alphanumeric — lowercase letters and digits only`;
1145
- const yamlText = fs
1146
- .readFileSync(absPath, "utf8")
1147
- .match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
1160
+ const yamlText = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
1148
1161
  err.position = positionOfYamlPath(yamlText, err.keyPath);
1149
1162
  if (err.position.line) err.position.line++;
1150
1163
  }
@@ -1186,13 +1199,13 @@ export function collectContentIndex(
1186
1199
  // Every note record exists and every Address in it is a tuple, so a parent
1187
1200
  // is found by its canonical address. Before the sort, which reads nothing
1188
1201
  // the pass writes.
1189
- resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems });
1202
+ resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems, parsed });
1190
1203
  const cultureNotes = records
1191
1204
  .filter((record) => isNoteRecord(record) && record.type === "lore")
1192
1205
  .map((record) => ({
1193
1206
  fm: record,
1194
1207
  file: noteFile(contentBase, record),
1195
- raw: fs.readFileSync(noteFile(contentBase, record), "utf8"),
1208
+ raw: parsed.get(noteFile(contentBase, record)).raw,
1196
1209
  }));
1197
1210
  const cultureByKey = new Map(
1198
1211
  cultureNotes.map((note) => [
@@ -1352,7 +1365,7 @@ function dependencyAddresses(config) {
1352
1365
  * aborting the derivation. Omitted, the note throws — which is the contract
1353
1366
  * the emitter needs, since an index missing a note asserts that it does not
1354
1367
  * exist.
1355
- * @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] -
1368
+ * @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [opts.parsed] -
1356
1369
  * Filled with each note's body and authored frontmatter, keyed by the note's
1357
1370
  * file. Handed to {@link module:engine/sql-tables.prepareTreeSqlTables}, it
1358
1371
  * spares that pass reading and parsing every note again.
@@ -71,9 +71,12 @@ export function poetryMarkdown(body) {
71
71
  .join("\n\n");
72
72
  }
73
73
 
74
+ import MarkdownIt from "markdown-it";
74
75
  import { slugify } from "./content-slug.mjs";
75
76
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
76
77
 
78
+ const structureParser = new MarkdownIt({ html: true });
79
+
77
80
  /** Read poetry code fences; start/end are inclusive zero-based source lines.
78
81
  * @param {string} source - Authored Markdown.
79
82
  */
@@ -81,8 +84,14 @@ export function scanPoetry(source) {
81
84
  const lines = String(source ?? "").split("\n"),
82
85
  blocks = [],
83
86
  errors = [];
87
+ const literalLines = new Set();
88
+ for (const token of structureParser.parse(String(source ?? ""), {})) {
89
+ if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
90
+ for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
91
+ }
84
92
  let opening = null;
85
93
  for (let i = 0; i < lines.length; i++) {
94
+ if (literalLines.has(i)) continue;
86
95
  const match = /^ *(`{3,}|~{3,})(.*)$/.exec(lines[i]);
87
96
  if (opening) {
88
97
  if (