@heroiclands/package-build 23.7.1 → 23.9.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 (96) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +32 -0
  3. package/bin/content-commands.mjs +32 -10
  4. package/ci/check-banners.mjs +37 -19
  5. package/container.mjs +48 -10
  6. package/docs/api.md +402 -58
  7. package/docs/authoring/first-note.md +1 -1
  8. package/docs/authoring/frontmatter.md +30 -1
  9. package/docs/authoring/links-and-markup.md +154 -56
  10. package/docs/commands.md +31 -12
  11. package/docs/content-format.md +25 -2
  12. package/docs/design.md +31 -0
  13. package/docs/diagnostics.md +1 -1
  14. package/docs/guides/e2e.md +37 -1
  15. package/docs/guides/packs.md +2 -1
  16. package/docs/guides/reference-pages.md +86 -63
  17. package/docs/project-setup.md +30 -15
  18. package/docs/reference/format-details.md +313 -132
  19. package/docs/reference/note-types.md +191 -174
  20. package/e2e.mjs +89 -2
  21. package/engine/anchors.mjs +7 -20
  22. package/engine/base-compiler.mjs +11 -18
  23. package/engine/compile-corpus.mjs +5 -3
  24. package/engine/content-alerts.mjs +4 -2
  25. package/engine/content-blocks.mjs +18 -36
  26. package/engine/content-format.yaml +1 -0
  27. package/engine/content-index.mjs +447 -33
  28. package/engine/content-links.mjs +43 -15
  29. package/engine/content-poetry.mjs +9 -0
  30. package/engine/content-tables.mjs +8 -4
  31. package/engine/culture-reach.mjs +61 -34
  32. package/engine/data-keys.mjs +3 -1
  33. package/engine/diagnostics.mjs +24 -11
  34. package/engine/dictionary-layout.mjs +151 -19
  35. package/engine/dictionary-rows.mjs +129 -79
  36. package/engine/foundry-entries.mjs +21 -3
  37. package/engine/frontmatter-lint.mjs +1 -1
  38. package/engine/generate.mjs +12 -22
  39. package/engine/glossary-entries.mjs +11 -11
  40. package/engine/heading-attributes.mjs +21 -28
  41. package/engine/helpers.mjs +20 -12
  42. package/engine/infobox.mjs +3 -0
  43. package/engine/journals.mjs +15 -2
  44. package/engine/language-data.mjs +53 -0
  45. package/engine/language-terms.mjs +86 -0
  46. package/engine/language-words.mjs +2 -2
  47. package/engine/lexical-sections.mjs +247 -0
  48. package/engine/markdownlint-run.mjs +12 -2
  49. package/engine/markup-validation.mjs +34 -0
  50. package/engine/note-addresses.mjs +1 -0
  51. package/engine/note-claims.mjs +21 -10
  52. package/engine/note-package.mjs +3 -5
  53. package/engine/note-state.mjs +9 -3
  54. package/engine/note-vocabulary.mjs +36 -7
  55. package/engine/pdf-build.mjs +17 -10
  56. package/engine/prose-lint.mjs +12 -6
  57. package/engine/related-pages.mjs +22 -3
  58. package/engine/see-also.mjs +150 -0
  59. package/engine/site-build.mjs +21 -3
  60. package/engine/site-preview.mjs +4 -2
  61. package/engine/sql-tables.mjs +287 -53
  62. package/engine/standings.mjs +14 -12
  63. package/engine/stub-lint.mjs +6 -2
  64. package/engine/web-wikilinks.mjs +8 -6
  65. package/engine/word-rows.mjs +20 -12
  66. package/engine/word-terms.mjs +15 -6
  67. package/hugo-theme/layouts/partials/related.html +25 -8
  68. package/package.json +2 -1
  69. package/types/container.d.mts +4 -3
  70. package/types/e2e.d.mts +28 -0
  71. package/types/engine/base-compiler.d.mts +2 -2
  72. package/types/engine/compile-corpus.d.mts +2 -1
  73. package/types/engine/content-index.d.mts +85 -8
  74. package/types/engine/content-tables.d.mts +2 -1
  75. package/types/engine/culture-reach.d.mts +22 -2
  76. package/types/engine/data-keys.d.mts +7 -0
  77. package/types/engine/diagnostics.d.mts +7 -6
  78. package/types/engine/dictionary-layout.d.mts +35 -4
  79. package/types/engine/dictionary-rows.d.mts +9 -0
  80. package/types/engine/foundry-entries.d.mts +3 -1
  81. package/types/engine/heading-attributes.d.mts +2 -2
  82. package/types/engine/helpers.d.mts +11 -5
  83. package/types/engine/language-terms.d.mts +7 -0
  84. package/types/engine/lexical-sections.d.mts +95 -0
  85. package/types/engine/markdownlint-run.d.mts +3 -1
  86. package/types/engine/markup-validation.d.mts +14 -0
  87. package/types/engine/note-addresses.d.mts +10 -0
  88. package/types/engine/note-claims.d.mts +7 -1
  89. package/types/engine/note-package.d.mts +3 -1
  90. package/types/engine/note-state.d.mts +2 -1
  91. package/types/engine/note-vocabulary.d.mts +1 -0
  92. package/types/engine/related-pages.d.mts +10 -1
  93. package/types/engine/see-also.d.mts +55 -0
  94. package/types/engine/site-build.d.mts +4 -1
  95. package/types/engine/sql-tables.d.mts +40 -6
  96. package/types/engine/word-rows.d.mts +9 -7
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,
@@ -39,10 +39,9 @@ import { scanAlerts } from "./content-alerts.mjs";
39
39
  import { scanBlocks } from "./content-blocks.mjs";
40
40
  import { scanPoetry } from "./content-poetry.mjs";
41
41
  import { scanSpans } from "./content-spans.mjs";
42
- import { codeRegions } from "./code-fences.mjs";
43
42
  import { slugify } from "./content-slug.mjs";
44
43
  import { scanFigures } from "./content-figures.mjs";
45
- import { HEADING_LINE, splitHeadingAttributes } from "./heading-attributes.mjs";
44
+ import { scanHeadingAttributes } from "./heading-attributes.mjs";
46
45
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
47
46
 
48
47
  /**
@@ -71,25 +70,13 @@ export const ANCHOR_KINDS = Object.freeze(["prose", "event"]);
71
70
  */
72
71
  export function collectAnchors(body, bodyLine = 1, resolveRole) {
73
72
  const anchors = [];
74
- const literal = codeRegions(body, { spans: false, poetryAsProse: false });
75
- let offset = 0;
76
- const lines = String(body ?? "").split("\n");
77
-
78
- for (let i = 0; i < lines.length; i++) {
79
- const lineOffset = offset;
80
- offset += lines[i].length + 1;
81
- if (literal.some((region) => lineOffset >= region.start && lineOffset < region.end))
82
- continue;
83
-
84
- const heading = HEADING_LINE.exec(lines[i]);
85
- if (!heading) continue;
86
- const attributes = splitHeadingAttributes(heading[2]);
87
- if (!attributes.id) continue;
73
+ for (const heading of scanHeadingAttributes(body, bodyLine).headings) {
74
+ if (!heading.id) continue;
88
75
  anchors.push({
89
- slug: attributes.id,
90
- name: attributes.text,
91
- level: heading[1].length,
92
- line: bodyLine + i,
76
+ slug: heading.id,
77
+ name: heading.text,
78
+ level: heading.level,
79
+ line: heading.line,
93
80
  kind: "prose",
94
81
  });
95
82
  }
@@ -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({
@@ -84,10 +84,12 @@ export async function buildCompileCorpus({
84
84
  });
85
85
  const corpusOptions = { config: resolved, records };
86
86
  const sqlOptions = { ...corpusOptions, skipDirectories, parsed };
87
+ const sqlTables = await prepareTreeSqlTables(contentBase, sqlOptions);
87
88
  return {
88
89
  records,
90
+ parsed,
89
91
  problems: collected,
90
- linkIndex: buildContentLinkIndex(contentBase, router, corpusOptions),
91
- sqlTables: await prepareTreeSqlTables(contentBase, sqlOptions),
92
+ linkIndex: buildContentLinkIndex(contentBase, router, { ...corpusOptions, sqlTables }),
93
+ sqlTables,
92
94
  };
93
95
  }
@@ -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,
@@ -201,6 +201,7 @@ types:
201
201
  - other
202
202
  skill:
203
203
  data:
204
+ - grammar
204
205
  - templatePriority
205
206
  - masteryLevel
206
207
  - parentSkill