@heroiclands/package-build 3.4.0 → 5.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 (40) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/CONTENT.md +100 -22
  3. package/MIGRATING.md +120 -0
  4. package/bin/content-build.mjs +6 -2
  5. package/content-config.mjs +109 -6
  6. package/engine/base-compiler.mjs +33 -36
  7. package/engine/content-links.mjs +15 -13
  8. package/engine/content-package.mjs +3 -2
  9. package/engine/field-reference.mjs +2 -2
  10. package/engine/frontmatter-lint.mjs +26 -0
  11. package/engine/helpers.mjs +10 -12
  12. package/engine/homepage.mjs +150 -0
  13. package/engine/index.mjs +10 -1
  14. package/engine/journals.mjs +2 -3
  15. package/engine/macros.mjs +2 -3
  16. package/engine/manifest-emit.mjs +17 -12
  17. package/engine/note-package.mjs +75 -68
  18. package/engine/note-schemas.mjs +44 -0
  19. package/engine/pack-router.mjs +3 -3
  20. package/engine/retired-fields.mjs +123 -0
  21. package/engine/scenes.mjs +7 -11
  22. package/engine/site-build.mjs +151 -22
  23. package/engine/site-index.mjs +5 -5
  24. package/package.json +1 -1
  25. package/sohl/actors.mjs +2 -3
  26. package/sohl/items.mjs +2 -3
  27. package/sohl/note-schemas.mjs +8 -0
  28. package/types/content-config.d.mts +105 -4
  29. package/types/engine/base-compiler.d.mts +12 -17
  30. package/types/engine/content-package.d.mts +3 -2
  31. package/types/engine/helpers.d.mts +5 -8
  32. package/types/engine/homepage.d.mts +118 -0
  33. package/types/engine/index.d.mts +3 -0
  34. package/types/engine/manifest-emit.d.mts +7 -8
  35. package/types/engine/note-package.d.mts +29 -34
  36. package/types/engine/note-schemas.d.mts +6 -0
  37. package/types/engine/pack-router.d.mts +3 -3
  38. package/types/engine/retired-fields.d.mts +54 -0
  39. package/types/engine/site-build.d.mts +52 -4
  40. package/types/sohl/note-schemas.d.mts +6 -0
package/engine/index.mjs CHANGED
@@ -54,9 +54,18 @@ export * as packRouter from "./pack-router.mjs";
54
54
  /** The content package a build compiles, and the Foundry package it ships in. */
55
55
  export * as contentPackage from "./content-package.mjs";
56
56
 
57
- /** Which content package a note belongs to, and refusing one that disagrees. */
57
+ /** Which content package a note belongs to, and refusing one that says so. */
58
58
  export * as notePackage from "./note-package.mjs";
59
59
 
60
+ /** Frontmatter fields a note may no longer declare, and the refusal of them. */
61
+ export * as retiredFields from "./retired-fields.mjs";
62
+
63
+ /** The package homepage: the note type that compiles to a page, not a document. */
64
+ export * as homepage from "./homepage.mjs";
65
+
66
+ /** The note types the engine itself declares, whatever a consumer registers. */
67
+ export * as noteSchemas from "./note-schemas.mjs";
68
+
60
69
  /** The shipped Foundry manifest: locating it, reading it, guarding its id. */
61
70
 
62
71
  /** The URL a content note is published at — the one web-address rule. */
@@ -38,9 +38,8 @@
38
38
  * Not a standalone script — exports the `Journals` compiler class, imported
39
39
  * and driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`).
40
40
  *
41
- * The walk itself — filtering by package and type, skipping drafts,
42
- * expanding tables, converting wikilinks, writing the JSON and counting
43
- * errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
41
+ * The walk itself — filtering by type, expanding tables, converting
42
+ * wikilinks, writing the JSON and counting errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
44
43
  * states only what makes this pass its own (#1509).
45
44
  */
46
45
 
package/engine/macros.mjs CHANGED
@@ -44,9 +44,8 @@
44
44
  * Not a standalone script — exports the `Macros` compiler class, imported and
45
45
  * driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`).
46
46
  *
47
- * The walk itself — filtering by package and type, skipping drafts,
48
- * expanding tables, converting wikilinks, writing the JSON and counting
49
- * errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
47
+ * The walk itself — filtering by type, expanding tables, converting
48
+ * wikilinks, writing the JSON and counting errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
50
49
  * states only what makes this pass its own (#1509).
51
50
  */
52
51
 
@@ -54,10 +54,12 @@ import { canonicalKey, writeManifests } from "./kb-manifest.mjs";
54
54
  import { walkMarkdownTree } from "./helpers.mjs";
55
55
  import { compendiumUuid, packForType, pageUuid } from "./ids.mjs";
56
56
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
57
- import { assertNotePackage } from "./note-package.mjs";
57
+ import { assertNoDeclaredPackage } from "./note-package.mjs";
58
+ import { assertNoDraftField } from "./retired-fields.mjs";
58
59
  import { journalPageId, splitPages } from "./journals.mjs";
59
60
  import { routerFor } from "./pack-router.mjs";
60
61
  import { loadPackConfig } from "./pack-config.mjs";
62
+ import { publishesContentPages } from "../content-config.mjs";
61
63
 
62
64
  /**
63
65
  * The reserved anchor name for a journal's **first** page.
@@ -193,14 +195,13 @@ export function entriesForNote(fm, name, address, body, ctx) {
193
195
  /**
194
196
  * Every note this package publishes, as manifest entries.
195
197
  *
196
- * Drafts are excluded because the site does not publish them, and an entry for
197
- * an unpublished page is exactly the dead link the manifest exists to prevent.
198
- *
199
- * Every note in the tree is this package's note, whether or not it says so:
200
- * `package:` is optional and merely has to agree (#56). A note naming a
201
- * different package **throws** rather than being skipped this build is not
202
- * authoritative for it, and skipping it silently is how a whole tree came to be
203
- * filtered out of a manifest that then claimed the package published nothing.
198
+ * Every note in the tree is this package's, so nothing here selects by package:
199
+ * the key's first segment is `contentPackage` (#56). A note still declaring the
200
+ * retired `package:` or `draft:` field **throws** rather than being skipped —
201
+ * skipping one silently is how a whole tree came to be filtered out of a
202
+ * manifest that then claimed the package published nothing, and it is what let
203
+ * a drafted note's inbound links look like links to a note that never existed
204
+ * (#69).
204
205
  *
205
206
  * A note that has no address is **reported, not guessed** — the finding carries
206
207
  * the file and the reason, so a caller can print it or fail on it. Inventing an
@@ -226,8 +227,12 @@ export function collectManifestEntries(contentBase, ctx) {
226
227
  )) {
227
228
  if (!fm) continue;
228
229
  const rel = path.relative(contentBase, absPath);
229
- assertNotePackage(fm, { file: rel, configured: ctx.contentPackage });
230
- if (fm.draft === true) continue;
230
+ assertNoDeclaredPackage(fm, {
231
+ file: rel,
232
+ absPath,
233
+ configured: ctx.contentPackage,
234
+ });
235
+ assertNoDraftField(fm, { file: rel, absPath });
231
236
  if (!fm.type || !fm.shortcode) continue;
232
237
 
233
238
  const base = path.basename(absPath);
@@ -267,7 +272,7 @@ export function manifestContext(config = loadPackConfig()) {
267
272
  foundryPackageId: config.foundryPackage,
268
273
  packRouter: routerFor(config),
269
274
  scheme: config.publish.address,
270
- web: config.publish.site,
275
+ web: publishesContentPages(config),
271
276
  // The walk's own configuration, threaded through rather than left to
272
277
  // its default, so a caller that passes a config drives every read.
273
278
  skipDirectories: config.skipDirectories,
@@ -14,10 +14,10 @@
14
14
  /**
15
15
  * Which content package a note belongs to.
16
16
  *
17
- * **It is the repository's configured `contentPackage`.** A content tree holds
18
- * exactly one package's notes — every package is single-sourced in the
19
- * repository that ships it — so the package is a property of the *repository*,
20
- * not of the note.
17
+ * **It is the repository's configured `contentPackage`, and nothing else.** A
18
+ * content tree holds exactly one package's notes — every package is
19
+ * single-sourced in the repository that ships it — so the package is a property
20
+ * of the *repository*, not of the note, and no note declares it.
21
21
  *
22
22
  * It used to be a property of the note, and a **selector**: the compile loop
23
23
  * read `package:` out of frontmatter and skipped anything that did not match
@@ -27,100 +27,107 @@
27
27
  * **zero notes and exited 0** — which is exactly the state the un-migrated
28
28
  * `hm-loc-*` / `hm-adv-*` repositories are in today.
29
29
  *
30
- * So the field is being retired, in three steps, of which this is the first:
30
+ * The field was retired in three steps, and all three have landed:
31
31
  *
32
- * 1. **Optional here.** An absent `package:` is normal and the note compiles; a
33
- * present one is accepted while it agrees, and is a loud, named error when
34
- * it does not. Non-breaking, so a consumer adopts it before changing a note.
35
- * 2. **Swept** out of every content tree in the org, on this version.
36
- * 3. **Rejected outright**, as a major, once the sweeps have merged.
32
+ * 1. **Optional** (3.3.0). An absent `package:` was normal and a present one
33
+ * was accepted while it agreed. Non-breaking, so a consumer adopted it
34
+ * before changing a note.
35
+ * 2. **Swept** out of every content tree on the org — 6,235 notes across
36
+ * `sohl`, `thalorna`, `kethira` and `harnensemble`.
37
+ * 3. **Rejected outright**, as this major. A note declaring the field fails the
38
+ * build naming the file, whatever the value says.
37
39
  *
38
- * The distinction between the two functions here is which question is being
39
- * asked. {@link assertNotePackage} is for a site that used to *select* it
40
- * answers "may this build compile this note", and a disagreement is a finding.
41
- * {@link notePackage} is for a site that *derives an address* it answers
42
- * "which package's namespace does this address sit in", where a disagreement
43
- * has already been reported by the compile pass and repeating it would print
44
- * the same fault twice.
40
+ * Step 3 is deliberately a rejection rather than continued tolerance: a field
41
+ * accepted while it agrees is a field that grows back, one note at a time, and
42
+ * every one of them is a line restating a constant the configuration already
43
+ * carries. There is no value that makes writing it correct, so the diagnostic
44
+ * says what to write instead rather than which value to change.
45
+ *
46
+ * **One `package` survives, and it is synthesised.** A `dataview` table scopes
47
+ * itself with `WHERE … and package = "<pkg>"`, and that clause resolves against
48
+ * frontmatter like any other field. {@link searchableFrontmatter} supplies the
49
+ * derived value to the search so the 45 authored clauses across `sohl` and
50
+ * `thalorna` keep matching. It is a *search* value, never an authored one, and
51
+ * it is never written back to a note.
45
52
  *
46
53
  * @module
47
54
  */
48
55
 
49
56
  import { contentPackage } from "./content-package.mjs";
57
+ import { locateFrontmatterKey } from "./retired-fields.mjs";
50
58
 
51
59
  /**
52
- * The package a note belongs to.
53
- *
54
- * Non-validating: the answer for a note that declares nothing, and for one that
55
- * declares the configured package, is the same value. A note declaring some
56
- * *other* package is answered literally here rather than corrected — the
57
- * compile pass reports that, once, through {@link assertNotePackage}.
58
- *
59
- * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
60
- * could not be parsed.
61
- * @param {string} [configured] - The package this build compiles. Defaults to
62
- * the configured `contentPackage`; passed explicitly by callers that already
63
- * carry it in a context object, so a caller's configuration drives every read.
64
- * @returns {string} The package.
65
- */
66
- export function notePackage(fm, configured) {
67
- const declared = fm?.package;
68
- // A blank is a declaration of nothing, not a package named "".
69
- if (declared != null && declared !== "") return declared;
70
- // Resolved only when it is needed, so a caller holding a note that declares
71
- // one never touches the configuration (#2).
72
- return configured ?? contentPackage();
73
- }
74
-
75
- /**
76
- * A note's frontmatter as a generated table searches it — its package present
77
- * whether or not the note declares one.
60
+ * A note's frontmatter as a generated table searches it — its package present,
61
+ * though no note declares one.
78
62
  *
79
63
  * A `dataview` query resolves `package` out of frontmatter like any other
80
64
  * field, so a collection note that scopes itself with `WHERE … and package =
81
- * "sohl"` matches nothing once the field is deleted, and renders an **empty
82
- * table** in silence. Deriving the value here keeps the two spellings
83
- * equivalent, so a sweep that deletes the field is mechanical rather than a
84
- * trap (#56) — and a query that never mentions `package` is unaffected either
85
- * way.
65
+ * "sohl"` would match nothing now that the field is gone, and would render an
66
+ * **empty table** in silence. Supplying the derived value here is what kept the
67
+ * sweep mechanical rather than a trap (#56) and a query that never mentions
68
+ * `package` is unaffected either way.
86
69
  *
87
- * The declared value is left alone when there is one, so nothing about an
88
- * unswept tree changes.
70
+ * The frontmatter is copied rather than written into: it is the note's own
71
+ * parsed object, shared with every other reader, and the derived package is a
72
+ * property of *this search*, not of the note.
89
73
  *
90
74
  * @param {object|null|undefined} fm - Parsed frontmatter.
91
- * @param {string} [configured] - The package this build compiles.
92
- * @returns {object|null|undefined} The frontmatter itself when it declares a
93
- * package, else a shallow copy carrying the derived one.
75
+ * @param {string} [configured] - The package this build compiles. Defaults to
76
+ * the configured `contentPackage`; passed explicitly by callers that already
77
+ * carry it in a context object, so a caller's configuration drives every read.
78
+ * @returns {object|null|undefined} A shallow copy carrying the derived package,
79
+ * or whatever was passed when it is not frontmatter at all.
94
80
  */
95
81
  export function searchableFrontmatter(fm, configured) {
96
82
  if (!fm || typeof fm !== "object") return fm;
97
- if (fm.package != null && fm.package !== "") return fm;
98
- return { ...fm, package: notePackage(fm, configured) };
83
+ return { ...fm, package: configured ?? contentPackage() };
99
84
  }
100
85
 
101
86
  /**
102
- * The package a note belongs to, refusing one that names another package.
87
+ * Refuse a note that declares `package:` at all.
103
88
  *
104
- * @param {object|null|undefined} fm - Parsed frontmatter.
89
+ * Presence is the whole test — a declaration that *agrees* with the
90
+ * configuration is as retired as one that disagrees, and an empty one
91
+ * (`package:`, which parses as `null`) is still the field.
92
+ *
93
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
94
+ * could not be parsed.
105
95
  * @param {object} [options] - Options.
106
96
  * @param {string} [options.file] - The note's path, named in the message. Omit
107
97
  * it where the caller emits through a diagnostic, which puts the locator at
108
98
  * the start of the line already — repeating it prints the path twice.
99
+ * @param {string} [options.absPath] - The note's file on disk, read only on the
100
+ * failing path to locate the offending line and column. The position rides on
101
+ * the thrown error as `position`, for a caller that emits a diagnostic.
109
102
  * @param {string} [options.configured] - The package this build compiles.
110
103
  * Defaults to the configured `contentPackage`.
111
- * @returns {string} The package, which is always `configured`.
112
- * @throws {Error} When the note declares a different package.
104
+ * @returns {void}
105
+ * @throws {Error} When the note declares the field.
113
106
  */
114
- export function assertNotePackage(fm, { file, configured } = {}) {
107
+ export function assertNoDeclaredPackage(
108
+ fm,
109
+ { file, absPath, configured } = {},
110
+ ) {
111
+ if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "package")) return;
112
+
115
113
  const target = configured ?? contentPackage();
116
- const pkg = notePackage(fm, target);
117
- if (pkg === target) return target;
118
- throw new Error(
119
- `note declares \`package: ${pkg}\`, but this repository compiles ` +
120
- `"${target}"` +
114
+ const declared = fm.package;
115
+ const wrote =
116
+ declared === null || declared === undefined || declared === "" ?
117
+ "`package:`"
118
+ : `\`package: ${declared}\``;
119
+
120
+ const err = new Error(
121
+ `${wrote} is a retired frontmatter field — delete it` +
121
122
  (file ? ` — ${file}` : "") +
122
- `. A note's package is the configured \`contentPackage\`, so the ` +
123
- `field is redundant and is being retired: delete it, or correct ` +
124
- `\`contentPackage\` in package-build.config.yaml.`,
123
+ `. A note's package is this repository's configured ` +
124
+ `\`contentPackage\` ("${target}", in package-build.config.yaml), ` +
125
+ `and every note in the tree belongs to it.`,
125
126
  );
127
+ // Where the field is, so the caller's diagnostic opens on the line that has
128
+ // to be deleted. Read here rather than carried through every walk: this is
129
+ // the failing path, and the build stops on it.
130
+ const position = locateFrontmatterKey(absPath, "package");
131
+ if (position) err.position = position;
132
+ throw err;
126
133
  }
@@ -0,0 +1,44 @@
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
+ /**
15
+ * The note types the **engine** declares — the ones whose vocabulary is a fact
16
+ * about the note format rather than about any game system (#51).
17
+ *
18
+ * `sohl/note-schemas.mjs` is the other half, and the line between them is the
19
+ * `engine/` ÷ `sohl/` line everywhere else in this package: note-format
20
+ * knowledge here, game-system knowledge there. It is not a permission boundary
21
+ * between consumers — every content project authors the full vocabulary — but it
22
+ * is a reachability one in exactly one direction. A package that declares no
23
+ * `itemBuilders` (`HarnMaster-3-FoundryVTT`, and every HM3 module) uses only the
24
+ * packaging half of the toolchain, so a type declared in the SoHL registry would
25
+ * be unavailable to it. These are the types every package has, whatever it
26
+ * ships.
27
+ *
28
+ * One entry today. A consumer merges it under its own registry —
29
+ * `{ ...ENGINE_NOTE_SCHEMAS, ...NOTE_SCHEMAS }` — so a game system may extend
30
+ * these but the engine's declaration stands wherever no registry is configured.
31
+ *
32
+ * @module
33
+ */
34
+
35
+ import { HOMEPAGE_FIELDS, HOMEPAGE_TYPE } from "./homepage.mjs";
36
+
37
+ /**
38
+ * Every engine-level content type, and what a note of that type may write.
39
+ *
40
+ * @type {Readonly<Record<string, readonly import("./field-spec.mjs").FieldSpec[]>>}
41
+ */
42
+ export const ENGINE_NOTE_SCHEMAS = Object.freeze({
43
+ [HOMEPAGE_TYPE]: HOMEPAGE_FIELDS,
44
+ });
@@ -69,10 +69,10 @@ export class PackRoutingError extends Error {
69
69
  /**
70
70
  * The frontmatter field a note declares its pack in.
71
71
  *
72
- * Deliberately close to the retiring `package:` and deliberately not the same
72
+ * Deliberately close to the retired `package:` and deliberately not the same
73
73
  * word: `package:` said which *distribution* owned a note — now the
74
- * repository's `contentPackage` (#56) — while `pack:` says which *compendium*
75
- * receives its document.
74
+ * repository's `contentPackage`, and no longer authorable (#56) — while `pack:`
75
+ * says which *compendium* receives its document.
76
76
  */
77
77
  export const PACK_FIELD = "pack";
78
78
 
@@ -0,0 +1,123 @@
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
+ /**
15
+ * Frontmatter fields a note may no longer declare.
16
+ *
17
+ * A retired field has exactly two possible fates, and only one of them is
18
+ * honest. Left honoured, it keeps doing whatever it did — which is why it was
19
+ * retired. Left *ignored*, it reads to its author as though it still works: the
20
+ * note says one thing and the build does another, and nothing says so. So a
21
+ * retired field is **refused**, naming the file and the line, and the message
22
+ * says what to write instead rather than which value to correct.
23
+ *
24
+ * `package:` is retired the same way and is refused from `note-package.mjs`,
25
+ * where the concept it belonged to still lives. `draft:` has no such home —
26
+ * there is no surviving concept it was part of — so it is refused here.
27
+ *
28
+ * **What `draft:` did (#69).** It excluded a note from the compiled packs, from
29
+ * the link manifest and from a consuming site build. Nothing reported the
30
+ * consequence: `content-links.mjs`, `site-index.mjs` and `content-lint.mjs`
31
+ * never read the field, so a link into a drafted note was indistinguishable
32
+ * from a link to a note that does not exist, and the checkers could not say
33
+ * which. The field's entire effect was to move a note from *published* to
34
+ * *unresolvable*, silently — and it also suppressed real build failures, since
35
+ * a note the compilers never reached could not fail on the defects it carried.
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import fs from "node:fs";
41
+
42
+ import { positionInFrontmatter } from "./diagnostics.mjs";
43
+
44
+ /**
45
+ * What a note declaring `draft:` is told, in one place.
46
+ *
47
+ * Written once and shared by the compile-time refusal and the frontmatter lint,
48
+ * because an author meets whichever of the two runs first and they should read
49
+ * the same. It says what the field did and what to write instead, rather than
50
+ * which value to correct: no value makes declaring it right.
51
+ *
52
+ * @param {string} [file] - The note's path, named in the message. Omit it where
53
+ * the caller emits through a diagnostic, whose locator already starts the
54
+ * line — repeating it prints the path twice.
55
+ * @returns {string} The message, unpunctuated at the end as a finding is.
56
+ */
57
+ export function draftRetiredMessage(file) {
58
+ return (
59
+ "`draft:` is a retired frontmatter field — delete it" +
60
+ (file ? ` — ${file}` : "") +
61
+ ". It excluded the note from the compiled packs, the link manifest " +
62
+ "and the site, and no checker reported the exclusion, so every " +
63
+ "wikilink into it read as a link to a note that does not exist. To " +
64
+ "mark a note as unfinished, tag it `#draft` instead: the build " +
65
+ "ignores tags, and a `FROM #draft` query still finds it"
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Refuse a note that declares `draft:` at all.
71
+ *
72
+ * Presence is the whole test. `draft: false` is as retired as `draft: true` —
73
+ * it reads as "publish this note", which is what happens either way, and is
74
+ * exactly the belief the message exists to correct.
75
+ *
76
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
77
+ * could not be parsed.
78
+ * @param {object} [options] - Options.
79
+ * @param {string} [options.file] - The note's path, named in the message. Omit
80
+ * it where the caller emits through a diagnostic, which puts the locator at
81
+ * the start of the line already — repeating it prints the path twice.
82
+ * @param {string} [options.absPath] - The note's file on disk, read only on the
83
+ * failing path to locate the offending line and column. The position rides on
84
+ * the thrown error as `position`, for a caller that emits a diagnostic.
85
+ * @returns {void}
86
+ * @throws {Error} When the note declares the field.
87
+ */
88
+ export function assertNoDraftField(fm, { file, absPath } = {}) {
89
+ if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "draft")) return;
90
+
91
+ const err = new Error(`${draftRetiredMessage(file)}.`);
92
+ // Where the field is, so the caller's diagnostic opens on the line that has
93
+ // to be deleted. Read here rather than carried through every walk: this is
94
+ // the failing path, and the build stops on it.
95
+ const position = locateFrontmatterKey(absPath, "draft");
96
+ if (position) err.position = position;
97
+ throw err;
98
+ }
99
+
100
+ /**
101
+ * A frontmatter key's position in a note's file, or nothing.
102
+ *
103
+ * {@link positionInFrontmatter} answers the same question from the file's
104
+ * *text*; this reads the file to ask it. Kept apart from either caller because
105
+ * both refusals need it and a second copy is a second thing to keep correct.
106
+ *
107
+ * @param {string|undefined} absPath - The note's file.
108
+ * @param {string} key - The top-level frontmatter key.
109
+ * @returns {{line?: number, column?: number}|undefined} Spreadable position
110
+ * fields, dropped rather than guessed when the file cannot be read or the key
111
+ * cannot be found — as `formatDiagnostic` requires.
112
+ */
113
+ export function locateFrontmatterKey(absPath, key) {
114
+ if (!absPath) return undefined;
115
+ let raw;
116
+ try {
117
+ raw = fs.readFileSync(absPath, "utf8");
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ const at = positionInFrontmatter(raw, key);
122
+ return at.line === undefined ? undefined : at;
123
+ }
package/engine/scenes.mjs CHANGED
@@ -42,9 +42,8 @@
42
42
  * Not a standalone script — exports the `Scenes` compiler class, imported and
43
43
  * driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`).
44
44
  *
45
- * The walk itself — filtering by package and type, skipping drafts,
46
- * expanding tables, converting wikilinks, writing the JSON and counting
47
- * errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
45
+ * The walk itself — filtering by type, expanding tables, converting
46
+ * wikilinks, writing the JSON and counting errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
48
47
  * states only what makes this pass its own (#1509).
49
48
  */
50
49
 
@@ -192,10 +191,11 @@ export class Scenes extends BasePackCompiler {
192
191
  for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(
193
192
  this.contentBase,
194
193
  )) {
195
- // No package test: every note in the tree is this package's, and
196
- // this pass's own walk the shared compile loop is where a note
197
- // declaring another one is reported, once (#56). Repeating the
198
- // check here would either double the diagnostic or throw past it.
194
+ // No retired-field test: this pass's own walk the shared compile
195
+ // loopis where a note still declaring `package:` (#56) or
196
+ // `draft:` (#69) is reported, once. Repeating either check here
197
+ // would double the diagnostic or throw past it. A refused note is
198
+ // indexed and then never compiled, so it reaches no document.
199
199
  if (!fm || !fm.id) continue;
200
200
  if (
201
201
  fm.shortcode &&
@@ -216,10 +216,6 @@ export class Scenes extends BasePackCompiler {
216
216
  });
217
217
  }
218
218
  if (!isMapType(fm.type)) continue;
219
- if (fm.draft === true) {
220
- log.debug(`Skipping draft map: ${absPath}`);
221
- continue;
222
- }
223
219
  maps.push({ fm, body, absPath });
224
220
  }
225
221
  return { maps, effectsByAddress };