@heroiclands/package-build 24.0.4 → 24.1.1

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 (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +8 -0
  3. package/assets/styles/content-base.css +25 -10
  4. package/bin/content-commands.mjs +183 -15
  5. package/bin/package-build.mjs +5 -170
  6. package/docs/authoring/links-and-markup.md +215 -179
  7. package/docs/commands.md +88 -2
  8. package/docs/configuration.md +6 -3
  9. package/docs/content-format.md +4 -3
  10. package/docs/diagnostics.md +57 -29
  11. package/docs/guides/book.md +1 -1
  12. package/docs/guides/module-project.md +2 -2
  13. package/docs/guides/site.md +2 -2
  14. package/docs/guides/writing-in-invented-scripts.md +175 -125
  15. package/docs/reference/format-details.md +53 -35
  16. package/docs/reference/note-types.md +10 -10
  17. package/docs/reference/theme-pages.md +18 -9
  18. package/engine/build-assets.mjs +179 -0
  19. package/engine/calendar-artifacts.mjs +7 -3
  20. package/engine/compendiums.mjs +16 -11
  21. package/engine/content-render.mjs +23 -2
  22. package/engine/content-scripts.mjs +348 -140
  23. package/engine/content-session.mjs +62 -14
  24. package/engine/content-spans.mjs +13 -1
  25. package/engine/content-timings.mjs +67 -0
  26. package/engine/content-verse.mjs +158 -373
  27. package/engine/note-vocabulary.mjs +5 -5
  28. package/engine/pdf-render.mjs +63 -44
  29. package/engine/run-rows.mjs +18 -20
  30. package/engine/script-font-delivery.mjs +22 -15
  31. package/engine/verse-metre.mjs +36 -8
  32. package/engine/written-in.mjs +9 -13
  33. package/package.json +1 -1
  34. package/site-theme/static/css/style.css +19 -10
  35. package/types/engine/build-assets.d.mts +23 -0
  36. package/types/engine/calendar-artifacts.d.mts +5 -1
  37. package/types/engine/content-scripts.d.mts +18 -7
  38. package/types/engine/content-session.d.mts +19 -1
  39. package/types/engine/content-timings.d.mts +14 -0
  40. package/types/engine/content-verse.d.mts +37 -163
  41. package/types/engine/run-rows.d.mts +16 -9
  42. package/types/engine/script-font-delivery.d.mts +8 -4
  43. package/types/engine/verse-metre.d.mts +4 -3
@@ -13,6 +13,9 @@
13
13
 
14
14
  /** One invocation's authored sources and context-specific prepared views. */
15
15
  import path from "node:path";
16
+ import fs from "node:fs";
17
+ import { DEPLOYED_ASSET_TYPES } from "./asset-types.mjs";
18
+ import { createContentTimings } from "./content-timings.mjs";
16
19
  import * as contentIndex from "./content-index.mjs";
17
20
  import * as blocks from "./content-blocks.mjs";
18
21
  import * as alerts from "./content-alerts.mjs";
@@ -52,6 +55,8 @@ import { lintNoteStates } from "./stub-lint.mjs";
52
55
  * @property {() => Promise<import("./content-index.mjs").PreparedContentIndex>} publication
53
56
  * @property {() => ReturnType<import("./compile-corpus.mjs").buildCompileCorpus>} compileCorpus
54
57
  * @property {(targets: readonly ContentTarget[]) => Promise<ContentSession>} prepare
58
+ * @property {ReturnType<typeof createContentTimings>} timings
59
+ * @property {() => void} assertUnchanged
55
60
  * @property {() => void} dispose
56
61
  */
57
62
 
@@ -71,6 +76,8 @@ export const CONTENT_TARGETS = Object.freeze(["index", "lint", "links", "compile
71
76
  * @param {string} [options.assetsBase] - Assets root, defaulting to configuration.
72
77
  * @param {readonly string[]} [options.skipDirectories] - Resolved content scope.
73
78
  * @param {ReturnType<typeof routerFor>} [options.router] - One router shared by this session's compile.
79
+ * @param {boolean} [options.trackChanges] - Snapshot physical input metadata for build phase guards.
80
+ * @param {ReturnType<typeof createContentTimings>} [options.timings] - Invocation stage accounting.
74
81
  * @returns {Promise<ContentSession>} Captured sources and lazy prepared views; dispose after use.
75
82
  */
76
83
  export async function createContentSession({
@@ -79,14 +86,36 @@ export async function createContentSession({
79
86
  assetsBase = config.paths.assets,
80
87
  skipDirectories = config.skipDirectories,
81
88
  router = routerFor(config),
89
+ timings = createContentTimings(),
90
+ trackChanges = false,
82
91
  } = {}) {
83
92
  const tree = path.resolve(contentBase);
84
93
  const assets = path.resolve(assetsBase);
85
94
  const scope = [...skipDirectories];
86
- const inventory = captureContentSources(tree, assets, scope);
95
+ const inventory = timings.sync("source capture", () =>
96
+ captureContentSources(tree, assets, scope),
97
+ );
98
+ const fingerprint = (file) => {
99
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
100
+ return stat ? `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}` : "absent";
101
+ };
102
+ const fingerprints =
103
+ trackChanges ?
104
+ new Map(
105
+ [
106
+ ...new Set([
107
+ tree,
108
+ ...DEPLOYED_ASSET_TYPES.map((entry) => path.resolve(assets, entry.root)),
109
+ ...inventory.paths.map((entry) => entry.file),
110
+ ]),
111
+ ].map((file) => [file, fingerprint(file)]),
112
+ )
113
+ : undefined;
87
114
  const parsed = new Map();
88
115
  const problems = [];
89
- const foreign = loadForeignIndexes(config, [config.contentPackage]);
116
+ const foreign = timings.sync("dependency indexes", () =>
117
+ loadForeignIndexes(config, [config.contentPackage]),
118
+ );
90
119
  const syntaxCache = new Map();
91
120
  const prepared = new Map();
92
121
  let disposed = false;
@@ -104,23 +133,31 @@ export async function createContentSession({
104
133
  });
105
134
  return syntaxCache.get(text);
106
135
  };
107
- const records = contentIndex.indexRecordsFor({
108
- config,
109
- contentBase: tree,
110
- assetsBase: assets,
111
- skipDirectories: scope,
112
- sources: inventory.indexSources,
113
- parsed,
114
- problems,
115
- foreignIndex: foreign.index,
116
- syntax,
117
- });
136
+ const records = timings.sync("index derivation", () =>
137
+ contentIndex.indexRecordsFor({
138
+ config,
139
+ contentBase: tree,
140
+ assetsBase: assets,
141
+ skipDirectories: scope,
142
+ sources: inventory.indexSources,
143
+ parsed,
144
+ problems,
145
+ foreignIndex: foreign.index,
146
+ syntax,
147
+ }),
148
+ );
118
149
  const memo = (key, make) => {
119
150
  active();
120
- if (!prepared.has(key)) prepared.set(key, Promise.resolve().then(make));
151
+ if (!prepared.has(key))
152
+ prepared.set(
153
+ key,
154
+ Promise.resolve().then(() => timings.measure(key, make)),
155
+ );
156
+ else timings.hit(key);
121
157
  return prepared.get(key);
122
158
  };
123
159
  const session = {
160
+ timings,
124
161
  config,
125
162
  contentBase: tree,
126
163
  assetsBase: assets,
@@ -133,6 +170,17 @@ export async function createContentSession({
133
170
  problems,
134
171
  foreign,
135
172
  syntax,
173
+ /** Refuse a stale capture after any build phase changes an input. */
174
+ assertUnchanged() {
175
+ active();
176
+ if (!fingerprints)
177
+ throw new Error("Content change tracking was not enabled for this session");
178
+ for (const [file, before] of fingerprints)
179
+ if (fingerprint(file) !== before)
180
+ throw new Error(
181
+ `Content sources changed during the build: ${file}. Finish source generation before content build.`,
182
+ );
183
+ },
136
184
  /** Refuse use with a different configuration, root, scope or router. */
137
185
  assertContext(options = {}) {
138
186
  active();
@@ -53,7 +53,8 @@ export function spanMarkdownPlugin(md, { spoilers = false, source = false } = {}
53
53
  /@[A-Za-z]+$/.test(state.src.slice(0, start)) ||
54
54
  state.src[start + 1] === "^" ||
55
55
  state.src[start + 1] === "!" ||
56
- ["!", "[", "]"].includes(state.src[start - 1])
56
+ ["!", "]"].includes(state.src[start - 1]) ||
57
+ (state.src[start - 1] === "[" && state.linkLevel === 0)
57
58
  )
58
59
  return false;
59
60
  const end = state.md.helpers.parseLinkLabel(state, start, false);
@@ -91,6 +92,17 @@ export function spanMarkdownPlugin(md, { spoilers = false, source = false } = {}
91
92
  finish = close + 1;
92
93
  }
93
94
  }
95
+ // A generated book link may put its language/script span directly
96
+ // inside the label. Preserve the literal class-only bracket spelling
97
+ // there for ordinary spans, including names used by the first-use audit.
98
+ if (
99
+ state.src[start - 1] === "[" &&
100
+ !["lang", "font", "map"].some((key) => Object.hasOwn(parsed.values, key)) &&
101
+ !parsed.classes.some(
102
+ (name) => name === "script" || /^font-(?:mono|serif|sans)$/.test(name),
103
+ )
104
+ )
105
+ return false;
94
106
  const problems = [...parsed.problems, ...refusedAttributes(parsed.values)];
95
107
  if (parsed.classes.includes("glyph")) problems.push(...glyphProblems(parsed));
96
108
  else if (parsed.classes.includes("cartouche")) problems.push(...cartoucheProblems(parsed));
@@ -0,0 +1,67 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /** Sequential, nested stage accounting for one content invocation. */
15
+ export function createContentTimings() {
16
+ const started = performance.now();
17
+ let changed = started;
18
+ const stack = ["orchestration"];
19
+ const stages = new Map();
20
+ const hits = new Map();
21
+ const account = () => {
22
+ const now = performance.now();
23
+ const name = stack.at(-1);
24
+ stages.set(name, (stages.get(name) ?? 0) + now - changed);
25
+ changed = now;
26
+ };
27
+ const begin = (name) => {
28
+ account();
29
+ stack.push(name);
30
+ return () => {
31
+ account();
32
+ stack.pop();
33
+ };
34
+ };
35
+ return {
36
+ /** @template T @param {string} name @param {() => T} work @returns {T} */
37
+ sync(name, work) {
38
+ const stop = begin(name);
39
+ try {
40
+ return work();
41
+ } finally {
42
+ stop();
43
+ }
44
+ },
45
+ /** @template T @param {string} name @param {() => Promise<T>|T} work @returns {Promise<T>} */
46
+ async measure(name, work) {
47
+ const stop = begin(name);
48
+ try {
49
+ return await work();
50
+ } finally {
51
+ stop();
52
+ }
53
+ },
54
+ /** @param {string} name */
55
+ hit(name) {
56
+ hits.set(name, (hits.get(name) ?? 0) + 1);
57
+ },
58
+ snapshot() {
59
+ account();
60
+ return {
61
+ totalMs: changed - started,
62
+ stages: Object.fromEntries(stages),
63
+ hits: Object.fromEntries(hits),
64
+ };
65
+ },
66
+ };
67
+ }