@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ Lint, Markdown checks and formatting now refuse malformed divs, alerts and poetry. Leave a blank line after a quote or alert before closing `:::`; formatting preserves malformed files for correction.
8
+
9
+ ### Patch Changes
10
+
11
+ - Authors can suggest related notes with optional explanations, shown in reading order on websites, in Foundry journals, and in books.
12
+ - End-to-end Cypress tests use the same pinned Chrome for Testing locally and in CI, with a reusable download cache and explicit browser overrides.
13
+ - Container tests wait for S3 to answer and explain how to recover safely when switching checkouts leaves an incompatible storage server.
14
+ - Content lint reports index, SQL and validation times in its final summary, making slow stages visible in build logs.
15
+ - Content lint and pack compilation spend less time repeating SQL queries and parsing notes, especially in large content packages.
16
+
3
17
  ## 23.7.1
4
18
 
5
19
  ### Patch Changes
package/README.md CHANGED
@@ -16,6 +16,33 @@ npm install -D @heroiclands/package-build
16
16
 
17
17
  The generated project includes a homepage, website and book targets, npm scripts, and a local `.gitignore`. [Getting started](docs/getting-started.md) routes you to a [system](docs/guides/system-project.md), [module](docs/guides/module-project.md), or [content authoring](docs/authoring/first-note.md) path. The [content-language-server](https://github.com/HeroicLands/content-language-server) provides editor navigation and completion.
18
18
 
19
+ ## Working on this repository
20
+
21
+ Run `npm ci` in each fresh clone or worktree. Its `prepare` step installs the
22
+ committed `.githooks/` with `git config core.hooksPath .githooks`; run
23
+ `npm run prepare` to install the hooks again without reinstalling dependencies.
24
+ Git uses the hook files in the current worktree.
25
+
26
+ These are the canonical POSIX shell hooks from
27
+ [HeroicLands/.github](https://github.com/HeroicLands/.github#local-git-hooks-and-actionsgithooks).
28
+ They need Git, POSIX utilities and an available UTF-8 locale, and run without
29
+ Node at commit time. `pre-commit` refuses commits on `main` and `master`;
30
+ `commit-msg` refuses assistant coauthor credits and standalone assistant
31
+ signatures while allowing ordinary prose and human coauthors. Both forges run
32
+ the pinned canonical drift action against every installed hook file.
33
+
34
+ Local opt-outs use Git configuration: `hooks.protectedBranch false` or
35
+ `hooks.allowCommitOnMain true` for the branch guard, and
36
+ `hooks.noAttribution false` for the message guard. Prefix each with `git config`
37
+ to set it for this clone. `git commit --no-verify` skips both checks once.
38
+ Server and pull-request enforcement still apply.
39
+
40
+ The published `githooks/` bundle is a separate optional API described in
41
+ [project setup](docs/project-setup.md#optional-published-hook-bundle). This
42
+ repository’s setup installs the two canonical shell hooks; it installs no
43
+ pre-push hook. Run `npm test` and the other checks directly, or use
44
+ `package-build ci` for a container run.
45
+
19
46
  ## Documentation
20
47
 
21
48
  The documentation lives in the Markdown files in this repository. Start with [the documentation home](docs/index.md). This toolchain does not build its own site or book. A Hugo theme dependency would form a cycle because the theme uses package-build for releases.
@@ -54,4 +81,9 @@ Every icon and image is covered by a provenance record stating `attribution`, `l
54
81
 
55
82
  Leading captions, generic fenced divs, inline spans, and poetry code fences are described in [links and markup](docs/authoring/links-and-markup.md).
56
83
 
84
+ Cypress end-to-end suites use pinned Chrome for Testing 155.0.8059.84 by default
85
+ on macOS arm64 and Linux x64, downloaded once into a reusable cache. Select an
86
+ installed browser with `package-build e2e fast -- --browser electron` (also
87
+ available for `run` and `open`). See [browser selection and cache settings](docs/guides/e2e.md#choose-a-browser).
88
+
57
89
  The package is GPL-3.0-or-later for code and CC-BY-SA-4.0 for original content. See [LICENSE.md](LICENSE.md).
@@ -101,6 +101,7 @@ import { HM3_ITEM_FIELDS } from "../hm3/item-fields.mjs";
101
101
  import { ENGINE_NOTE_SCHEMAS } from "../engine/note-schemas.mjs";
102
102
  import { schemaSubtypeOf } from "../engine/subtype-registry.mjs";
103
103
  import { NOTE_VOCABULARY } from "../engine/note-vocabulary.mjs";
104
+ import { markupFindings } from "../engine/markup-validation.mjs";
104
105
  import { checkFormatting, checkPrettierConventions, lintMarkdown } from "../engine/prose-lint.mjs";
105
106
  import { lintProse, scoreProseTree } from "../engine/readability-lint.mjs";
106
107
  import { loadPackageBuildConfig } from "../config.mjs";
@@ -917,6 +918,7 @@ function lintCommand() {
917
918
  // reader needs a line to open, not a stack.
918
919
  const corpusProblems = [];
919
920
  const parsed = new Map();
921
+ const indexStarted = performance.now();
920
922
  const records = indexRecordsFor({
921
923
  contentBase: root,
922
924
  config,
@@ -924,12 +926,23 @@ function lintCommand() {
924
926
  problems: corpusProblems,
925
927
  parsed,
926
928
  });
929
+ const indexMs = performance.now() - indexStarted;
927
930
  for (const problem of corpusProblems) emitDiagnostic(problem);
928
931
  // An error whatever the command's own strictness: the note is
929
932
  // absent from every answer below, so reporting it and exiting 0
930
933
  // would call the tree clean while silently omitting a note.
931
934
  if (corpusProblems.length) process.exitCode = 1;
932
935
 
936
+ const sqlStarted = performance.now();
937
+ const sqlTables = await prepareTreeSqlTables(root, {
938
+ config,
939
+ records,
940
+ parsed,
941
+ skipDirectories: config.skipDirectories,
942
+ });
943
+ const sqlMs = performance.now() - sqlStarted;
944
+ const validationStarted = performance.now();
945
+
933
946
  // The package is passed for the homepage rule, which
934
947
  // names the address a tree with no front page fails to serve.
935
948
  const addresses = lintContentTree(root, {
@@ -945,12 +958,7 @@ function lintCommand() {
945
958
  config,
946
959
  records,
947
960
  skipDirectories: config.skipDirectories,
948
- sqlTables: await prepareTreeSqlTables(root, {
949
- config,
950
- records,
951
- parsed,
952
- skipDirectories: config.skipDirectories,
953
- }),
961
+ sqlTables,
954
962
  });
955
963
  // Which system blocks this tree carries, derived from what it
956
964
  // declares it ships for. Read before the lint so the
@@ -1182,6 +1190,7 @@ function lintCommand() {
1182
1190
  });
1183
1191
 
1184
1192
  const findings = [
1193
+ ...index.notes.flatMap((note) => markupFindings(note.raw, note.file)),
1185
1194
  ...addresses.findings,
1186
1195
  ...frontmatter.findings,
1187
1196
  ...schemaFindings,
@@ -1198,6 +1207,7 @@ function lintCommand() {
1198
1207
  // Only an **error** fails the run. `reportFindings` draws that
1199
1208
  // line, so the rule is the shared one rather than a second
1200
1209
  // copy here. Each finding names its own file.
1210
+ const validationMs = performance.now() - validationStarted;
1201
1211
  const errors = reportFindings(findings, {});
1202
1212
  if (errors) {
1203
1213
  log.error(`${findings.length} finding(s) across ${addresses.notes} note(s).`);
@@ -1214,6 +1224,10 @@ function lintCommand() {
1214
1224
  `${addresses.notes} note(s)).`,
1215
1225
  );
1216
1226
  }
1227
+ log.info(
1228
+ `Stage timings: index ${indexMs.toFixed(1)} ms; ` +
1229
+ `SQL ${sqlMs.toFixed(1)} ms; validation ${validationMs.toFixed(1)} ms.`,
1230
+ );
1217
1231
  } catch (err) {
1218
1232
  reportFailure(err);
1219
1233
  process.exitCode = 1;
@@ -16,21 +16,16 @@
16
16
  *
17
17
  * `partials/hero-banner.html` needs to know which banner images exist, and Hugo
18
18
  * cannot ask a remote asset host that question at build time — so the answer is
19
- * declared in `data/banners.yaml` instead. A declaration nothing
20
- * checks is kept in step by hope, and the hole it was written to close reopens
21
- * the first time it drifts. It already had: when this check was written, four
22
- * names present in a local working copy of the artwork repository were not
23
- * published, and three that were published appeared in no local copy at all.
24
- *
25
- * So this fetches every declared name and fails on any non-200. It is the test
26
- * the template cannot make, moved to the one place that can make it.
19
+ * declared in `data/banners.yaml` instead. This checks every declaration and
20
+ * accepts HTTP 200 or 206. HTTP 404 and 410 identify absent artwork; other HTTP
21
+ * statuses and transport errors leave publication unverified and fail the check.
27
22
  *
28
23
  * It also refuses a name in `data/banner-kinds.yaml` that `banners.yaml` does
29
24
  * not declare, before any request is made: a mapping to undrawn art is a dead
30
25
  * URL on every page of that kind.
31
26
  *
32
27
  * Usage:
33
- * node utils/check-banners.mjs [--base <url>] [--concurrency <n>]
28
+ * node ci/check-banners.mjs [--base <url>] [--concurrency <n>]
34
29
  *
35
30
  * The host defaults to the Heroic Lands CDN and can be pointed anywhere with
36
31
  * `--base` or `BANNER_BASE_URL`, so a consumer publishing its own artwork set
@@ -158,6 +153,20 @@ function checkMapping(available) {
158
153
  if (undeclared.length) process.exit(1);
159
154
  }
160
155
 
156
+ /** Fetch wraps transport errors in a cause, which can hold several connection failures. */
157
+ function networkErrorDetails(error, seen = new Set()) {
158
+ if (!error || seen.has(error)) return [];
159
+ seen.add(error);
160
+ const message = [error.code, error.message].filter(Boolean).join(": ");
161
+ return [
162
+ ...(message ? [message] : []),
163
+ ...networkErrorDetails(error.cause, seen),
164
+ ...(Array.isArray(error.errors) ?
165
+ error.errors.flatMap((cause) => networkErrorDetails(cause, seen))
166
+ : []),
167
+ ];
168
+ }
169
+
161
170
  async function probe(url) {
162
171
  try {
163
172
  // HEAD first — it is what the question actually is. Some object stores
@@ -172,10 +181,15 @@ async function probe(url) {
172
181
  }
173
182
  return {
174
183
  ok: res.status === 200 || res.status === 206,
175
- status: String(res.status),
184
+ missing: res.status === 404 || res.status === 410,
185
+ status: `HTTP ${res.status}`,
176
186
  };
177
187
  } catch (err) {
178
- return { ok: false, status: `network error: ${err.message}` };
188
+ return {
189
+ ok: false,
190
+ missing: false,
191
+ status: `network error: ${networkErrorDetails(err).join("; ") || String(err)}`,
192
+ };
179
193
  }
180
194
  }
181
195
 
@@ -190,26 +204,30 @@ async function main() {
190
204
  const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
191
205
  for (let name = queue.shift(); name !== undefined; name = queue.shift()) {
192
206
  const url = `${root}/${name}.webp`;
193
- const { ok, status } = await probe(url);
194
- if (!ok) failures.push({ name, url, status });
207
+ const { ok, missing, status } = await probe(url);
208
+ if (!ok) failures.push({ name, url, missing, status });
195
209
  }
196
210
  });
197
211
  await Promise.all(workers);
198
212
 
199
213
  if (failures.length) {
200
214
  failures.sort((a, b) => a.name.localeCompare(b.name));
201
- for (const { name, url, status } of failures) {
215
+ for (const { name, url, missing, status } of failures) {
202
216
  diag(
203
217
  lineOf(source, name),
204
- `declared banner ${name} is not published (${status}): ${url}`,
218
+ `declared banner ${name} ${missing ? "is not published" : "could not be verified"} (${status}): ${url}`,
205
219
  );
206
220
  }
221
+ const missing = failures.filter((failure) => failure.missing).length;
222
+ const unverified = failures.length - missing;
207
223
  console.error(
208
- `check-banners: ${failures.length} of ${available.length} declared banners are missing from ${base}.`,
209
- );
210
- console.error(
211
- "Publish the artwork, or drop the name — an undrawn name here is a 404 on every page that resolves to it.",
224
+ `check-banners: ${failures.length} of ${available.length} declared banners failed verification at ${base} (${missing} missing, ${unverified} unverified).`,
212
225
  );
226
+ if (missing) console.error("Publish the missing artwork or correct its inventory entry.");
227
+ if (unverified)
228
+ console.error(
229
+ "Check the asset host and request failures before changing the inventory.",
230
+ );
213
231
  process.exit(1);
214
232
  }
215
233
 
package/container.mjs CHANGED
@@ -492,8 +492,8 @@ export function s3ShellArgs(plan, command) {
492
492
  }
493
493
 
494
494
  /**
495
- * Start the S3 server on its network and create the bucket, unless it is
496
- * already running, and copy the `awsConfig` into the data root.
495
+ * Start or reuse the S3 server on its network, wait for readiness, ensure the
496
+ * bucket exists, and copy the `awsConfig` into the data root.
497
497
  *
498
498
  * @param {object} opts
499
499
  * @param {S3Plan} opts.plan - The S3 plan.
@@ -506,7 +506,8 @@ export function s3ShellArgs(plan, command) {
506
506
  * @param {(ms: number) => void} [opts.sleep] - Waits.
507
507
  * @param {number} [opts.attempts] - How often to ask the server whether it is
508
508
  * ready.
509
- * @throws {Error} When an input file is missing or the server never answers.
509
+ * @throws {Error} When an input is not a readable regular file, a reused
510
+ * identities mount is incompatible, or the S3 HTTP service never answers.
510
511
  */
511
512
  export function ensureS3({
512
513
  plan,
@@ -521,6 +522,10 @@ export function ensureS3({
521
522
  if (!fs.existsSync(file)) {
522
523
  throw new Error(`FOUNDRYVTT_CONTAINER_S3 needs ${file}, which does not exist.`);
523
524
  }
525
+ if (!fs.statSync(file).isFile()) {
526
+ throw new Error(`FOUNDRYVTT_CONTAINER_S3 needs ${file} to be a regular file.`);
527
+ }
528
+ fs.accessSync(file, fs.constants.R_OK);
524
529
  }
525
530
  const configDir = path.join(dataRoot, "Config");
526
531
  fs.mkdirSync(configDir, { recursive: true });
@@ -540,6 +545,32 @@ export function ensureS3({
540
545
  "{{.Names}}",
541
546
  ]);
542
547
  if (present.split("\n").includes(plan.server)) {
548
+ let identitiesMount;
549
+ try {
550
+ const mounts = JSON.parse(
551
+ capture(["inspect", "--format", "{{json .Mounts}}", plan.server]),
552
+ );
553
+ identitiesMount = mounts.find(
554
+ (mount) => mount.Destination === "/etc/seaweedfs/s3.json",
555
+ );
556
+ } catch {
557
+ // An unreadable inspection cannot establish that this server is safe to reuse.
558
+ }
559
+ const source = identitiesMount?.Source;
560
+ const canonical = (file) =>
561
+ fs.existsSync(file) ? fs.realpathSync(file) : path.resolve(file);
562
+ if (
563
+ identitiesMount?.Type !== "bind" ||
564
+ typeof source !== "string" ||
565
+ canonical(source) !== canonical(plan.identities)
566
+ ) {
567
+ throw new Error(
568
+ `S3 server '${plan.server}' has an incompatible identities mount ` +
569
+ `(found ${source ?? "no readable bind"}; expected ${plan.identities}). ` +
570
+ `Restore the original checkout, or remove only this disposable S3 container ` +
571
+ `with 'docker rm -f ${plan.server}' and rerun. Removing it discards its bucket data.`,
572
+ );
573
+ }
543
574
  const running = capture([
544
575
  "ps",
545
576
  "--filter",
@@ -548,15 +579,22 @@ export function ensureS3({
548
579
  "{{.Names}}",
549
580
  ]);
550
581
  if (!running.split("\n").includes(plan.server)) run(["start", plan.server]);
551
- return;
582
+ } else {
583
+ log(`Starting S3 server '${plan.server}' (${S3_IMAGE}) as ${S3_ALIAS}:${S3_PORT}`);
584
+ const status = run(s3RunArgs(plan));
585
+ if (status !== 0) throw new Error(`Starting the S3 server failed with status ${status}.`);
552
586
  }
553
- log(`Starting S3 server '${plan.server}' (${S3_IMAGE}) as ${S3_ALIAS}:${S3_PORT}`);
554
- const status = run(s3RunArgs(plan));
555
- if (status !== 0) throw new Error(`Starting the S3 server failed with status ${status}.`);
556
- // `weed shell` exits 0 whether or not the filer is up; it names the filers
557
- // it found once there is one.
587
+ // A ready filer does not mean the S3 listener has started. SeaweedFS's
588
+ // unauthenticated health route runs on that listener.
589
+ const probe = [
590
+ "exec",
591
+ plan.server,
592
+ "sh",
593
+ "-c",
594
+ `wget -q -T 2 -O /dev/null http://127.0.0.1:${S3_PORT}/healthz && echo ready`,
595
+ ];
558
596
  for (let attempt = 0; ; attempt++) {
559
- if (capture(s3ShellArgs(plan, "s3.bucket.list")).includes("filers: [")) break;
597
+ if (capture(probe).trim() === "ready") break;
560
598
  if (attempt >= attempts) {
561
599
  throw new Error(`The S3 server '${plan.server}' did not become ready.`);
562
600
  }