@heroiclands/package-build 6.0.0 → 7.0.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 (43) hide show
  1. package/CHANGELOG.md +798 -0
  2. package/CONTENT.md +228 -4
  3. package/bin/content-build.mjs +196 -10
  4. package/bin/package-build.mjs +8 -1
  5. package/config.mjs +25 -3
  6. package/content-config.mjs +283 -29
  7. package/engine/address-diff.mjs +290 -0
  8. package/engine/base-compiler.mjs +25 -0
  9. package/engine/content-links.mjs +132 -27
  10. package/engine/content-lint.mjs +23 -2
  11. package/engine/diagnostics.mjs +61 -1
  12. package/engine/frontmatter-lint.mjs +22 -0
  13. package/engine/generate.mjs +10 -5
  14. package/engine/helpers.mjs +38 -0
  15. package/engine/homepage.mjs +206 -2
  16. package/engine/journals.mjs +8 -1
  17. package/engine/macros.mjs +2 -0
  18. package/engine/pack-config.mjs +143 -13
  19. package/engine/prose-lint.mjs +10 -2
  20. package/engine/scenes.mjs +2 -2
  21. package/engine/site-build.mjs +74 -19
  22. package/engine/web-wikilinks.mjs +13 -4
  23. package/engine/wikilink-syntax.mjs +25 -0
  24. package/engine/wikilinks.mjs +6 -3
  25. package/manifest.mjs +37 -2
  26. package/package.json +5 -3
  27. package/sohl/actors.mjs +23 -10
  28. package/sohl/items.mjs +1 -1
  29. package/types/content-config.d.mts +14 -0
  30. package/types/engine/address-diff.d.mts +108 -0
  31. package/types/engine/base-compiler.d.mts +18 -1
  32. package/types/engine/content-links.d.mts +11 -3
  33. package/types/engine/content-lint.d.mts +4 -1
  34. package/types/engine/diagnostics.d.mts +33 -1
  35. package/types/engine/generate.d.mts +3 -2
  36. package/types/engine/helpers.d.mts +29 -3
  37. package/types/engine/homepage.d.mts +117 -0
  38. package/types/engine/journals.d.mts +7 -1
  39. package/types/engine/pack-config.d.mts +22 -0
  40. package/types/engine/prose-lint.d.mts +10 -2
  41. package/types/engine/site-build.d.mts +28 -3
  42. package/types/engine/wikilink-syntax.d.mts +24 -0
  43. package/types/sohl/actors.d.mts +3 -3
@@ -289,12 +289,25 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
289
289
  * resolves to nothing — yields `{}`, so a caller spreads the result and the
290
290
  * position is dropped rather than guessed.
291
291
  *
292
+ * **`key: true` addresses the declaration rather than the value.** A finding
293
+ * about a *value* — this pack name is not in `packs[]` — belongs on the value,
294
+ * which is the default. A finding that names a **field** — `\`site.sections.x\`
295
+ * is not a recognized option` — sends the reader to look for that field, so the
296
+ * position should be the field's own, and in a flow mapping
297
+ * (`{ title: X, banner: Y }`) the two are different columns on one line. The
298
+ * key's node is found through the same parse, so this stays one locator rather
299
+ * than a second one free to disagree with it.
300
+ *
292
301
  * @param {string} text - The document's contents.
293
302
  * @param {ReadonlyArray<string|number>} keyPath - Path to the node: map keys as
294
303
  * strings, sequence entries as numbers.
304
+ * @param {object} [opts]
305
+ * @param {boolean} [opts.key=false] - Report where the last segment is
306
+ * *declared* rather than where its value sits. Ignored for a sequence entry,
307
+ * which has no key.
295
308
  * @returns {{line?: number, column?: number}} Spreadable position fields.
296
309
  */
297
- export function positionOfYamlPath(text, keyPath) {
310
+ export function positionOfYamlPath(text, keyPath, { key = false } = {}) {
298
311
  if (typeof text !== "string" || !text) return {};
299
312
  if (!Array.isArray(keyPath) || keyPath.length === 0) return {};
300
313
 
@@ -305,6 +318,19 @@ export function positionOfYamlPath(text, keyPath) {
305
318
  // `keepScalar` returns the Scalar node rather than its value, which is
306
319
  // the only form carrying a range.
307
320
  node = doc.getIn(keyPath, true);
321
+ if (key && node !== undefined) {
322
+ const last = keyPath[keyPath.length - 1];
323
+ const parent =
324
+ keyPath.length === 1 ?
325
+ doc.contents
326
+ : doc.getIn(keyPath.slice(0, -1), true);
327
+ const pair = parent?.items?.find?.(
328
+ (item) =>
329
+ item?.key != null &&
330
+ String(item.key.value) === String(last),
331
+ );
332
+ if (pair?.key?.range) node = pair.key;
333
+ }
308
334
  } catch {
309
335
  return {};
310
336
  }
@@ -314,3 +340,37 @@ export function positionOfYamlPath(text, keyPath) {
314
340
  const { line, col } = counter.linePos(start);
315
341
  return { line, column: col };
316
342
  }
343
+
344
+ /**
345
+ * The YAML key path a **dotted field path** addresses.
346
+ *
347
+ * Configuration checks report the offending key as the path a reader would
348
+ * write it — `packs[1].name`, `site.sections.affliction.title` — because that
349
+ * is what the message has to say. {@link positionOfYamlPath} addresses a node
350
+ * by segments instead, so this is the one translation between them: `.`
351
+ * separates map keys, and a bracketed suffix is a sequence index.
352
+ *
353
+ * A path this cannot parse yields `[]`, which {@link positionOfYamlPath} in
354
+ * turn resolves to no position — dropped rather than guessed, as everything
355
+ * else here is.
356
+ *
357
+ * @param {string} field - The dotted path, as a diagnostic spells it.
358
+ * @returns {Array<string|number>} Its segments, sequence indices as numbers.
359
+ */
360
+ export function yamlKeyPath(field) {
361
+ if (typeof field !== "string" || field === "") return [];
362
+ /** @type {Array<string|number>} */
363
+ const segments = [];
364
+ for (const segment of field.split(".")) {
365
+ const parsed = /^([^[\]]*)((?:\[\d+\])*)$/.exec(segment);
366
+ // Anything else is not a path this understands — a key holding a `[`,
367
+ // or a dot inside a key. Refuse the whole path rather than resolve
368
+ // part of it to a node that is not the one named.
369
+ if (!parsed) return [];
370
+ if (parsed[1] !== "") segments.push(parsed[1]);
371
+ for (const index of parsed[2].matchAll(/\[(\d+)\]/g)) {
372
+ segments.push(Number(index[1]));
373
+ }
374
+ }
375
+ return segments;
376
+ }
@@ -40,6 +40,13 @@
40
40
  * vocabulary (an adventure module ships skills, beings and magic swords), so
41
41
  * every consumer loads all of it.
42
42
  *
43
+ * The two rules that are not schema-driven sit on the *note format* side of
44
+ * that line, which is why they are here and not in `sohl/`: the retired
45
+ * top-level fields, and the address-bearing fields a `type: homepage` note
46
+ * refuses (#53). Each supplies its own message from the module that owns the
47
+ * knowledge — `retired-fields.mjs` and `homepage.mjs` — and this module only
48
+ * locates it in the file.
49
+ *
43
50
  * **It takes a built link index rather than walking itself.** The dead-
44
51
  * reference check has to resolve exactly as a wikilink does, cross-package
45
52
  * manifests and all, and the way to guarantee that is to call the same
@@ -50,6 +57,7 @@
50
57
 
51
58
  import { authoredFields } from "./field-spec.mjs";
52
59
  import { positionInFrontmatter } from "./diagnostics.mjs";
60
+ import { checkHomepageAddressFields } from "./homepage.mjs";
53
61
  import { RETIRED_TYPES } from "./ids.mjs";
54
62
  import { draftRetiredMessage } from "./retired-fields.mjs";
55
63
 
@@ -237,6 +245,20 @@ export function lintNote(note, { schemas, index }) {
237
245
  });
238
246
  }
239
247
 
248
+ // A homepage's address is its package's, so the top-level fields that
249
+ // decide an address decide nothing on it (#53). Reported beside the retired
250
+ // fields above because it is the same kind of statement — a top-level key
251
+ // this note may not write — and, like them, it must survive the two early
252
+ // returns below: the finding stands whatever else the type is.
253
+ for (const { key, message } of checkHomepageAddressFields(fm)) {
254
+ findings.push({
255
+ file: note.file,
256
+ ...at(key),
257
+ severity: "error",
258
+ message,
259
+ });
260
+ }
261
+
240
262
  const replacement = RETIRED_TYPES[type];
241
263
  if (replacement) {
242
264
  findings.push({
@@ -49,7 +49,7 @@ import { Actors } from "../sohl/actors.mjs";
49
49
  import { Macros } from "./macros.mjs";
50
50
  import { Scenes } from "./scenes.mjs";
51
51
  import {
52
- buildStats,
52
+ statsForPack,
53
53
  loadFolders,
54
54
  buildFolderResolver,
55
55
  writeFolderDocs,
@@ -102,8 +102,9 @@ export const packJsonDir = (name, config = loadPackConfig()) =>
102
102
  * @param {object} [config] - The resolved build configuration. Defaults to this
103
103
  * repository's.
104
104
  * @returns {string[]} Each Item pack's JSON directory. Empty when the
105
- * repository ships no items at all the actors pass, which is the only
106
- * caller that needs one, refuses that itself.
105
+ * repository ships no items at all, which is a legitimate package: the actors
106
+ * pass accepts an empty list and reports an item it cannot resolve per
107
+ * `(type, shortcode)` instead, naming the being (#49).
107
108
  */
108
109
  export function itemPackJsonDirs(config = loadPackConfig()) {
109
110
  return config.packs
@@ -246,7 +247,7 @@ export function unsatisfiedPassDependencies(running, config) {
246
247
  * count (0 on success) and the number of entries it wrote.
247
248
  */
248
249
  async function generatePack(
249
- { name, type, folders, companions },
250
+ { name, type, folders, companions, system },
250
251
  config,
251
252
  router,
252
253
  routingReporter,
@@ -290,7 +291,9 @@ async function generatePack(
290
291
  companionDests[companion.name] = companionDest;
291
292
  }
292
293
 
293
- writeFolderDocs(folderList, buildStats(undefined, config), dest, type);
294
+ // A folder document belongs to the pack it is written into, so it carries
295
+ // that pack's system rather than the package-wide one (#48).
296
+ writeFolderDocs(folderList, statsForPack(system, config), dest, type);
294
297
 
295
298
  const pack = new packClass({
296
299
  contentBase,
@@ -311,6 +314,8 @@ async function generatePack(
311
314
  foreignSourceDirs: foreignItemCatalogDirs(config),
312
315
  folderResolver: resolver,
313
316
  packName: name,
317
+ // Which system this pack's documents are stamped for (#48).
318
+ packSystem: system ?? null,
314
319
  docType: type,
315
320
  router,
316
321
  routingReporter,
@@ -335,6 +335,44 @@ export function buildStats(
335
335
  };
336
336
  }
337
337
 
338
+ /**
339
+ * The `_stats` block for one pack, stamped with the system that pack is for
340
+ * (#48).
341
+ *
342
+ * **`systemId` travels with `systemVersion`.** They are one decision, so where
343
+ * one is omitted both are. Stamping a per-pack version against a package-wide
344
+ * id would emit `systemId: sohl, systemVersion: 1.6.3` on HM3 documents — a
345
+ * *plausible lie*, which is worse than the missing value #43 fixed, because
346
+ * nothing about it looks wrong.
347
+ *
348
+ * Resolution, in order:
349
+ *
350
+ * 1. The pack's own `system:`, looked up in the `systems:` block. That is the
351
+ * case a module shipping for two systems needs, and the one no
352
+ * package-wide value could express.
353
+ * 2. Failing that, the package-wide `stats` — a package whose packs are all for
354
+ * one system, which is every package that worked before this existed.
355
+ *
356
+ * A pack naming a system is validated against `systems:` at configuration time,
357
+ * so an unresolvable name never reaches here.
358
+ *
359
+ * @param {string|null|undefined} packSystem - The pack's declared `system:`.
360
+ * @param {object} [config] - The resolved configuration.
361
+ * @returns {object} The `_stats` block for that pack.
362
+ */
363
+ export function statsForPack(packSystem, config = loadPackConfig()) {
364
+ const declared = packSystem ? config.systems?.[packSystem] : null;
365
+ if (!declared) return buildStats(undefined, config);
366
+ return {
367
+ systemId: packSystem,
368
+ systemVersion: declared.compatibility.verified,
369
+ coreVersion: supportedCoreVersion(config),
370
+ createdTime: 0,
371
+ modifiedTime: 0,
372
+ lastModifiedBy: config.stats.lastModifiedBy,
373
+ };
374
+ }
375
+
338
376
  /** Memoised {@link defaultStats}. */
339
377
  let cachedDefaultStats;
340
378
 
@@ -49,14 +49,18 @@
49
49
  *
50
50
  * **Its address is the package's, not the note's.** A homepage publishes at
51
51
  * `/<contentPackage>/` because that is where the package is, so `name.full`,
52
- * `shortcode` and `id` decide nothing on it (#53 refuses them outright; this
53
- * module simply never reads them). It compiles into no document, so it carries
52
+ * `shortcode` and `id` decide nothing on it nothing here reads them, and
53
+ * {@link HOMEPAGE_REFUSED_FIELDS} refuses them outright rather than leaving an
54
+ * author to believe they worked (#53). It compiles into no document, so it carries
54
55
  * no compendium UUID and appears in no pack and in no link-manifest entry.
55
56
  *
56
57
  * @module
57
58
  */
58
59
 
60
+ import fs from "node:fs";
61
+
59
62
  import { matchAllOutsideCode } from "./code-fences.mjs";
63
+ import { formatLocator, positionInFrontmatter } from "./diagnostics.mjs";
60
64
 
61
65
  /**
62
66
  * The note type that compiles to the package homepage.
@@ -98,6 +102,206 @@ export function isHomepage(fm) {
98
102
  return Boolean(fm) && fm.type === HOMEPAGE_TYPE;
99
103
  }
100
104
 
105
+ /**
106
+ * The top-level fields a homepage refuses, and what each one would decide (#53).
107
+ *
108
+ * A note's URL derives from `name.full` and its identity from
109
+ * `(type, shortcode)`. The homepage is the one page for which neither holds: it
110
+ * publishes at `/<package>/`, fixed by the package id. An author fluent in the
111
+ * conventions writes them here expecting exactly what they do everywhere else,
112
+ * and gets none of it.
113
+ *
114
+ * **They were never inert, which is why ignoring them was the wrong answer.** A
115
+ * `shortcode` puts the note in the address index and in the `dataview` link
116
+ * universe, so `[[homepage-<shortcode>]]` resolves *green* — to
117
+ * `homepage/<slug>/`, an address derived from `name.full` and published by
118
+ * nothing, because a homepage is written to {@link HOMEPAGE_DESTINATION} at the
119
+ * package root. A build that reports a live link to a 404 is worse than one
120
+ * that says nothing. It also inflates `content-build lint`'s address tally, so
121
+ * the lint and the link manifest disagree about what the package publishes.
122
+ *
123
+ * **A named class, not an allow-list, and that boundary is the decision.** The
124
+ * documented envelope is `type` plus an optional `title`, and `landing`,
125
+ * `description` and `banner` are legitimate beside them — but a homepage's
126
+ * frontmatter is *emitted into the published page*
127
+ * ({@link homepageFrontmatter}), so an unrecognised key is a Hugo or theme
128
+ * parameter this build has never heard of and has no standing to refuse.
129
+ * Rejecting unknown keys would make every new theme parameter wait on a
130
+ * package-build release. What is refused is the specific class that makes a
131
+ * false claim about *where this page is*.
132
+ *
133
+ * `aliases` is deliberately not in the class: {@link homepageFrontmatter}
134
+ * already drops it from every emitted page, with a reason of its own, so
135
+ * authoring one is the same no-op it is on any other page rather than a wrong
136
+ * belief about this one's address.
137
+ *
138
+ * @type {ReadonlyMap<string, string>}
139
+ */
140
+ export const HOMEPAGE_REFUSED_FIELDS = Object.freeze(
141
+ new Map([
142
+ [
143
+ "name",
144
+ "`name` decides nothing on a `type: homepage` note: a page's slug " +
145
+ "derives from `name.full`, and a homepage's destination is " +
146
+ `fixed — it is written to \`${HOMEPAGE_DESTINATION}\` at the ` +
147
+ "package's own address, `/<package>/`. Write `title:` for what " +
148
+ "the page is called, and delete `name`",
149
+ ],
150
+ [
151
+ "shortcode",
152
+ "`shortcode` decides nothing on a `type: homepage` note: this " +
153
+ "page's address is the package's own, `/<package>/`, fixed by " +
154
+ "the package id. It is not ignored either — it puts the note " +
155
+ "in the address index, so `[[homepage-<shortcode>]]` resolves " +
156
+ "to a page the site build never writes. Delete it",
157
+ ],
158
+ [
159
+ "id",
160
+ "`id` decides nothing on a `type: homepage` note: it is the " +
161
+ "Foundry document id a compendium UUID is built from, and a " +
162
+ "homepage compiles into no document — it appears in no pack " +
163
+ "and in no link manifest. Delete it",
164
+ ],
165
+ ]),
166
+ );
167
+
168
+ /**
169
+ * The address-bearing fields one note authors, in the order it authored them.
170
+ *
171
+ * Authoring order rather than declaration order, so a caller emitting one
172
+ * diagnostic per finding emits them top to bottom down the file — the order a
173
+ * reader and a compiler-output parser both expect.
174
+ *
175
+ * Presence is the whole test: `shortcode:` authored empty still says "this page
176
+ * has an address of its own", and a value cannot make the claim true.
177
+ *
178
+ * Returned without a locator, because the two things that would supply one —
179
+ * the raw note text and the position helper — belong to the caller. This
180
+ * mirrors {@link module:engine/retired-fields}, whose retired-field messages
181
+ * are likewise positioned by whoever reports them.
182
+ *
183
+ * @param {object|null|undefined} fm - Parsed frontmatter.
184
+ * @returns {Array<{key: string, message: string}>} One entry per field the note
185
+ * authored, empty for any note that is not a homepage.
186
+ */
187
+ export function checkHomepageAddressFields(fm) {
188
+ if (!isHomepage(fm)) return [];
189
+ const out = [];
190
+ for (const key of Object.keys(fm)) {
191
+ const message = HOMEPAGE_REFUSED_FIELDS.get(key);
192
+ if (message) out.push({ key, message });
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /**
198
+ * Require exactly one homepage note in a content tree (#52).
199
+ *
200
+ * "Exactly one" is two rules, and they are **one severity** because they are
201
+ * one defect: a package whose front page is not the page a person chose.
202
+ *
203
+ * - _None_ and the package serves nothing at `/<package>/`. That is the failure
204
+ * #50 exists to prevent, and it is silent — the site build reports `wrote 0
205
+ * homepage(s)` and exits 0.
206
+ * - _Two_ and it serves a page nobody chose. Every homepage is written to the
207
+ * same {@link HOMEPAGE_DESTINATION}, so the second overwrites the first and
208
+ * the package's front page is decided by the order the walk happened to reach
209
+ * the files in — by *filename*, on a type whose whole point is that it is
210
+ * routed by frontmatter. There is no "first wins" convention to fall back on,
211
+ * so nothing here can pick the right one.
212
+ *
213
+ * Neither has a safe default, so neither is a warning. A warning is the right
214
+ * severity for something a build can proceed past correctly, and a build that
215
+ * proceeds past either of these publishes the wrong front page while reporting
216
+ * success — which is the exact outcome a warning would be tolerating.
217
+ *
218
+ * **Two is reported once per note, not once for the tree.** Each note is a
219
+ * place an author has to open and edit, and a single finding saying "there are
220
+ * two" sends them hunting for the second.
221
+ *
222
+ * **None is located at the tree, honestly.** There is no file to name, so the
223
+ * locator is the content root — the directory the note is missing from, which
224
+ * is a real path and the one the author adds it to. No line and no column are
225
+ * invented for it, per the diagnostic rules in
226
+ * {@link module:engine/diagnostics}. {@link lintContentTree} already reports an
227
+ * empty walk against the same locator.
228
+ *
229
+ * The rule reads no `site:` configuration and does not vary by
230
+ * `publish.site`: that setting chooses whether the *content* surfaces are
231
+ * published, and the homepage is the floor underneath both modes.
232
+ *
233
+ * @param {ReadonlyArray<{file: string}>} found - The homepage notes, in walk
234
+ * order. Paths may be absolute or relative to the working directory.
235
+ * @param {object} options - Options.
236
+ * @param {string} options.contentBase - Root of the content tree, for the
237
+ * locator when there is no file to name.
238
+ * @param {string} [options.contentPackage] - The package this tree builds.
239
+ * Dropped from the message when unknown rather than guessed.
240
+ * @returns {Array<{file: string, line?: number, column?: number,
241
+ * severity: "error", message: string}>} The findings, one per offending note.
242
+ */
243
+ export function checkHomepageCount(found, { contentBase, contentPackage }) {
244
+ const pages = found ?? [];
245
+ const named = contentPackage ? ` "${contentPackage}"` : "";
246
+ const address = contentPackage ? ` /${contentPackage}/` : "";
247
+
248
+ if (pages.length === 0) {
249
+ return [
250
+ {
251
+ file: contentBase,
252
+ severity: "error",
253
+ message:
254
+ `holds no \`type: homepage\` note, so ` +
255
+ `${contentPackage ? `package${named}` : "this package"} ` +
256
+ `publishes nothing at its own address${address} — a ` +
257
+ `package's front page is one authored note in this tree, ` +
258
+ `routed by \`type:\` rather than by filename`,
259
+ },
260
+ ];
261
+ }
262
+ if (pages.length === 1) return [];
263
+
264
+ return pages.map((page) => {
265
+ const others = pages
266
+ .filter((p) => p !== page)
267
+ .map((p) => formatLocator({ file: p.file }));
268
+ return {
269
+ file: page.file,
270
+ ...positionOfType(page.file),
271
+ severity: "error",
272
+ message:
273
+ `duplicate \`type: homepage\` note, also declared by ` +
274
+ `${others.join(", ")}; a package has one front page` +
275
+ `${contentPackage ? `, at${address},` : ""} and every ` +
276
+ `homepage is written to the same \`${HOMEPAGE_DESTINATION}\` — ` +
277
+ `so the one the walk reaches last silently overwrites the rest`,
278
+ };
279
+ });
280
+ }
281
+
282
+ /**
283
+ * Where a note declares `type: homepage`, when the file can still be read.
284
+ *
285
+ * A separate read rather than a raw text threaded through every caller: the
286
+ * two call sites hold different shapes (a lint note, a collected page) and this
287
+ * runs only on a tree that is already failing.
288
+ *
289
+ * @param {string} file - Path to the note.
290
+ * @returns {{line?: number, column?: number}} Spreadable position fields, empty
291
+ * when the file cannot be read — dropped rather than guessed.
292
+ */
293
+ function positionOfType(file) {
294
+ try {
295
+ return positionInFrontmatter(
296
+ fs.readFileSync(file, "utf8"),
297
+ "type",
298
+ HOMEPAGE_TYPE,
299
+ );
300
+ } catch {
301
+ return {};
302
+ }
303
+ }
304
+
101
305
  /**
102
306
  * The title a homepage publishes under.
103
307
  *
@@ -230,6 +230,11 @@ export function buildPages(rawPages, entryId, noteName) {
230
230
  * heading; see {@link splitPages}.
231
231
  * @param {string|null} [params.folder] - The folder id, or `null`.
232
232
  * @param {object} [params.flags] - Document flags.
233
+ * @param {object} [params.stats] - The `_stats` block to stamp. Passed by the
234
+ * caller because it is a property of the *pack* being written, not of the
235
+ * entry: a module may ship the same content for two systems, and each pack's
236
+ * documents record the system version they were built against (#48). A
237
+ * caller with no pack in hand gets the package-wide block.
233
238
  * @returns {object} The JournalEntry document, keyed for the pack.
234
239
  */
235
240
  export function buildJournalEntry({
@@ -239,6 +244,7 @@ export function buildJournalEntry({
239
244
  leadName,
240
245
  folder = null,
241
246
  flags,
247
+ stats = defaultStats(),
242
248
  }) {
243
249
  const rawPages = splitPages(markdown, leadName);
244
250
  const pages = buildPages(rawPages, id, name);
@@ -250,7 +256,7 @@ export function buildJournalEntry({
250
256
  ownership: { default: 0 },
251
257
  flags: flags || {},
252
258
  _id: id,
253
- _stats: defaultStats(),
259
+ _stats: stats,
254
260
  _key: `!journal!${id}`,
255
261
  };
256
262
  }
@@ -347,6 +353,7 @@ export class Journals extends BasePackCompiler {
347
353
  leadName: ownsDoc ? name : undefined,
348
354
  folder,
349
355
  flags: fm.flags,
356
+ stats: this.stats,
350
357
  });
351
358
  }
352
359
 
package/engine/macros.mjs CHANGED
@@ -322,6 +322,8 @@ export class Macros extends BasePackCompiler {
322
322
  return buildMacroEntry(fm, {
323
323
  command: macroCommand(body, name),
324
324
  folder: this.folderResolver(sohlField(fm, "folder", null)),
325
+ // This pack's system, not the package-wide one (#48).
326
+ stats: this.stats,
325
327
  });
326
328
  }
327
329