@heroiclands/package-build 22.27.0 → 22.28.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.28.0
4
+
5
+ ### Minor Changes
6
+
7
+ Notes now require a nonempty body, including drafts, folders and homepages. Short written notes still warn unless tagged as drafts.
8
+
3
9
  ## 22.27.0
4
10
 
5
11
  ### Minor Changes
@@ -226,7 +226,12 @@ const SHIPPED_ITEM_FIELDS = { sohl: ITEM_FIELDS, hm3: HM3_ITEM_FIELDS };
226
226
  /** Register the content operations on the package command line. */
227
227
  export function registerContentCommands(cli) {
228
228
  return cli
229
- .command(withIndexPreflight(packageCommand(), (_config, argv) => argv.action === "compile"))
229
+ .command(
230
+ withIndexPreflight(
231
+ withBodyPreflight(packageCommand(), (_config, argv) => argv.action === "compile"),
232
+ (_config, argv) => argv.action === "compile",
233
+ ),
234
+ )
230
235
  .command(depsCommand())
231
236
  .command(docsCommand())
232
237
  .command(withIndexPreflight(lintCommand()))
@@ -235,11 +240,17 @@ export function registerContentCommands(cli) {
235
240
  .command(withIndexPreflight(linksCommand()))
236
241
  .command(formatCommand())
237
242
  .command(markdownCommand())
238
- .command(contentIndexCommand())
239
- .command(withIndexPreflight(siteCommand(), publishesContentPages))
243
+ .command(
244
+ withBodyPreflight(
245
+ contentIndexCommand(),
246
+ () => true,
247
+ (config, argv) => argv.root || config.paths.content,
248
+ ),
249
+ )
250
+ .command(withIndexPreflight(withBodyPreflight(siteCommand()), publishesContentPages))
240
251
  .command(
241
252
  withIndexPreflight(
242
- pdfCommand(),
253
+ withBodyPreflight(pdfCommand(), (config) => Boolean(config.pdf)),
243
254
  (config) => config.pdf && publishesContentPages(config),
244
255
  ),
245
256
  )
@@ -380,6 +391,43 @@ function proseCommand() {
380
391
  };
381
392
  }
382
393
 
394
+ /** Refuse invalid note bodies before emitting metadata, packs, pages or a book. */
395
+ function withBodyPreflight(
396
+ command,
397
+ shouldCheck = () => true,
398
+ contentRoot = (config) => config.paths.content,
399
+ ) {
400
+ const handler = command.handler;
401
+ return {
402
+ ...command,
403
+ handler: async (argv) => {
404
+ try {
405
+ const config = loadPackConfig();
406
+ if (shouldCheck(config, argv)) {
407
+ const contentBase = contentRoot(config, argv);
408
+ if (!fs.existsSync(contentBase)) return handler(argv);
409
+ const records = indexRecordsFor({ config, contentBase });
410
+ const { findings } = lintNoteStates(contentBase, {
411
+ records,
412
+ contentPackage: config.contentPackage,
413
+ });
414
+ const errors = findings.filter((finding) => finding.severity === "error");
415
+ if (errors.length) {
416
+ for (const finding of errors) emitDiagnostic(finding);
417
+ process.exitCode = 1;
418
+ return;
419
+ }
420
+ }
421
+ } catch (err) {
422
+ reportFailure(err);
423
+ process.exitCode = 1;
424
+ return;
425
+ }
426
+ return handler(argv);
427
+ },
428
+ };
429
+ }
430
+
383
431
  /** Check declared dependency indexes before an index-consuming command does any work. */
384
432
  function withIndexPreflight(command, shouldCheck = () => true) {
385
433
  const handler = command.handler;
@@ -1167,7 +1215,7 @@ function lintCommand() {
1167
1215
  skipDirectories: config.skipDirectories,
1168
1216
  });
1169
1217
 
1170
- // Whether every empty body is a stub on purpose, and how
1218
+ // Whether every note has a body, and how
1171
1219
  // finished the tree is. The counts and the oldest drafts are
1172
1220
  // prose rather than findings: a note nobody has finished is not
1173
1221
  // wrong, and 464 warnings a reader cannot clear is how a report
@@ -0,0 +1,49 @@
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
+ * Publish this package to npm, with provenance wherever it can be generated.
16
+ *
17
+ * `publishConfig.provenance` is `true`, and npm refuses a publish with
18
+ * `Automatic provenance generation not supported for provider: null` anywhere
19
+ * that cannot mint a Sigstore identity. That is a CI runner holding an OIDC
20
+ * token: GitHub Actions with `id-token: write`, which exposes
21
+ * `ACTIONS_ID_TOKEN_REQUEST_URL`, or GitLab CI with `SIGSTORE_ID_TOKEN`.
22
+ * Everywhere else — a desk — this passes `--provenance=false`, so the publish
23
+ * goes ahead unattested rather than failing.
24
+ *
25
+ * Arguments after `--` reach `npm publish` unchanged, so
26
+ * `npm run deploy:npm -- --dry-run` shows the tarball without publishing it.
27
+ * `prepack` builds the declarations and the asset index on the way.
28
+ *
29
+ * Not a `package-build` subcommand: it publishes this package and no other.
30
+ */
31
+
32
+ import { spawnSync } from "node:child_process";
33
+
34
+ const env = process.env;
35
+ const provenance =
36
+ (env.GITHUB_ACTIONS === "true" && Boolean(env.ACTIONS_ID_TOKEN_REQUEST_URL)) ||
37
+ (env.GITLAB_CI === "true" && Boolean(env.SIGSTORE_ID_TOKEN));
38
+
39
+ if (!provenance) {
40
+ console.log("deploy:npm: no OIDC identity here; publishing without provenance.");
41
+ }
42
+
43
+ const args = ["publish", ...(provenance ? ["--provenance"] : ["--provenance=false"])];
44
+ const result = spawnSync("npm", [...args, ...process.argv.slice(2)], {
45
+ stdio: "inherit",
46
+ shell: process.platform === "win32",
47
+ });
48
+ if (result.error) throw result.error;
49
+ process.exit(result.status ?? 1);
package/docs/api.md CHANGED
@@ -441,16 +441,16 @@ Drawing a declared infobox: HTML for separate Foundry pages appended after autho
441
441
 
442
442
  The three states of a note — **stub**, **draft**, **full** — and the one observable fact they rest on. A note whose body is empty is a stub: it carries its facts into the index and is queryable there, and it has no page, no address and no link target. Nothing is stored: the ladder is derived once in the SQL `entries` view and no note can assert its own state.
443
443
 
444
- | Export | Signature | Returns | Use it when |
445
- | --------------------- | ------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
446
- | `NOTE_STATES` | `const NOTE_STATES` | `ReadonlyArray<string>` | naming the three states in ladder order |
447
- | `isEmptyBody` | `isEmptyBody(body)` | `boolean` | asking the exact question: is everything after the closing frontmatter fence whitespace? |
448
- | `isStubbableType` | `isStubbableType(type, vocabulary)` | `boolean` | asking whether an empty body suppresses this type's page; `folder` and `homepage` are structural and exempt |
449
- | `isStubNote` | `isStubNote(frontmatter, body, vocabulary)` | `boolean` | deciding whether one note is a stub, which is what the index and the site build both ask |
450
- | `journalHasContent` | `journalHasContent(body)` | `boolean` | asking whether the JournalEntry a body compiles into would hold anything, which is what decides whether one is made at all |
451
- | `PLACEHOLDER_PHRASES` | `const PLACEHOLDER_PHRASES` | `ReadonlyArray<string>` | reading the phrases that stand in for prose nobody has written |
452
- | `placeholderBody` | `placeholderBody(body)` | `Array<{phrase: string, line: number}>` | finding a body that reduces to nothing but placeholders, which is an abandoned draft rather than a stub |
453
- | `bodyWordCount` | `bodyWordCount(body)` | `number` | counting rendered prose, with wikilinks as one word and closed dashes, slashes, and ellipses separating adjacent words |
444
+ | Export | Signature | Returns | Use it when |
445
+ | --------------------- | ------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
446
+ | `NOTE_STATES` | `const NOTE_STATES` | `ReadonlyArray<string>` | naming the three states in ladder order |
447
+ | `isEmptyBody` | `isEmptyBody(body)` | `boolean` | asking the exact question: is everything after the closing frontmatter fence whitespace? |
448
+ | `isStubbableType` | `isStubbableType(type, vocabulary)` | `boolean` | asking whether an empty body suppresses this type's page; `folder` and `homepage` retain their addresses in this classification, but still require bodies |
449
+ | `isStubNote` | `isStubNote(frontmatter, body, vocabulary)` | `boolean` | deciding whether one note is a stub, which is what the index and the site build both ask |
450
+ | `journalHasContent` | `journalHasContent(body)` | `boolean` | asking whether the JournalEntry a body compiles into would hold anything, which is what decides whether one is made at all |
451
+ | `PLACEHOLDER_PHRASES` | `const PLACEHOLDER_PHRASES` | `ReadonlyArray<string>` | reading the phrases that stand in for prose nobody has written |
452
+ | `placeholderBody` | `placeholderBody(body)` | `Array<{phrase: string, line: number}>` | finding a body that reduces to nothing but placeholders, which is an abandoned draft rather than a stub |
453
+ | `bodyWordCount` | `bodyWordCount(body)` | `number` | counting rendered prose, with wikilinks as one word and closed dashes, slashes, and ellipses separating adjacent words |
454
454
 
455
455
  ### `engine.draftNotice`
456
456
 
@@ -471,7 +471,7 @@ What a draft note says about itself, in one sentence, on every surface it reache
471
471
 
472
472
  ### `engine.stubLint`
473
473
 
474
- An empty body must be deliberate. Four rules hold the state honest — a stub states what it is, a stub is not tagged `draft`, a body that renders to nothing is an abandoned draft, and a short unmarked body is asked whether it forgot its marker — and two reports say what the rules cannot decide.
474
+ Every typed note must have a nonempty body. Empty and whitespace-only bodies are errors regardless of description or `draft` tag, including structural types such as `folder` and `homepage`. Placeholder-only bodies also produce errors. Nonempty bodies with fewer than 25 prose words produce warnings unless tagged `draft`. Untyped vault scaffolding is excluded. The lint, content-index, package compile, site and PDF commands enforce body errors before publishing. The state classification helpers still describe incomplete records for inspection; their classifications do not exempt notes from validation.
475
475
 
476
476
  | Export | Signature | Returns | Use it when |
477
477
  | ------------------ | ---------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------- |
@@ -45,4 +45,4 @@ The full Address is `<package>-note-lore-harbor`; `<package>` comes from `conten
45
45
 
46
46
  Add `description` for a short page summary, `tags: [draft]` to mark unfinished prose, or `data` for fields the note's type permits. A system block such as `sohl:` or `hm3:` adds mechanics and can produce a Foundry Actor or Item. It does not replace the shared prose or make another JournalEntry. [Frontmatter](frontmatter.md) explains the allowed keys, and the [note-type reference](../reference/note-types.md) lists every type-specific field.
47
47
 
48
- A note with an empty body is a stub. It remains addressable as data, while a document that would have no content is omitted. See [note states](../reference/format-details.md#three-states-of-a-note) for the precise emission rules.
48
+ Every typed note must have a nonempty body after its frontmatter. An empty or whitespace-only body is an error, even with `tags: [draft]` or a description. This includes folder and homepage notes. A nonempty body with fewer than 25 prose words produces a warning unless the note has a `draft` tag. See [note states](../reference/format-details.md#three-states-of-a-note).
@@ -9,6 +9,8 @@ subType: userguide
9
9
 
10
10
  A content package begins with Markdown notes under `assets/content/`. Each note has YAML frontmatter describing its identity and data, followed by prose. Folder names beneath `assets/content/` are for authors; `type`, `subType`, and Addresses determine what the build creates.
11
11
 
12
+ Every typed note requires a nonempty body, including drafts, folders and homepages. An empty or whitespace-only body is an error. A nonempty body with fewer than 25 prose words warns unless tagged `draft`; the tag never exempts an empty body.
13
+
12
14
  Start with [your first note](authoring/first-note.md). It explains the smallest valid note and shows the JournalEntry, web page, book entry, and content-index record made from it. Then use the chapters below in the order your note needs them.
13
15
 
14
16
  | Need | Read |
@@ -2006,8 +2006,18 @@ convention rather than declared in a field — see
2006
2006
 
2007
2007
  ### Three states of a note
2008
2008
 
2009
- A note has three states, and two of them are nothing but conventions the format
2010
- already carried.
2009
+ Every typed note requires a nonempty body after its closing frontmatter fence.
2010
+ An empty or whitespace-only body is an error, regardless of description or
2011
+ `tags: [draft]`. This includes `folder` and `homepage` notes. Untyped vault
2012
+ scaffolding is excluded. A nonempty body with fewer than 25 prose words warns
2013
+ unless tagged `draft`; the tag never exempts an empty body.
2014
+
2015
+ The `lint`, `content-index`, `package compile`, `site` and `pdf` commands enforce
2016
+ body errors. Write a short beginning and tag it `draft` when work is unfinished.
2017
+
2018
+ The internal classification below describes records, including incomplete notes
2019
+ that inspection APIs can still read. A **stub** is invalid authored content and
2020
+ must receive a body before these commands can publish it.
2011
2021
 
2012
2022
  | State | Body | Marker | Page | Document | Link target |
2013
2023
  | --------- | ------- | --------------- | ------------ | --------------------------------- | ----------- |
@@ -2039,13 +2049,13 @@ same.
2039
2049
 
2040
2050
  | After the fence | Empty? | What happens |
2041
2051
  | ----------------------- | ------ | --------------------------------------------------------------- |
2042
- | Nothing, or blank lines | yes | A stub |
2052
+ | Nothing, or blank lines | yes | An error, with or without `draft` |
2043
2053
  | An HTML comment | no | A note with a body that renders to nothing. The lint reports it |
2044
2054
  | A horizontal rule | no | The same |
2045
2055
  | A lone heading | no | The same |
2046
2056
  | `_To be written._` | no | The same |
2047
2057
 
2048
- An HTML comment is deliberately not empty. An invisible marker does not make a valid stub; use an empty body when the note has no prose.
2058
+ An HTML comment is deliberately not empty, but an invisible marker does not supply prose. Replace placeholder-only content with written text.
2049
2059
 
2050
2060
  #### What a stub emits
2051
2061
 
@@ -2090,12 +2100,12 @@ What is suppressed with the page is the web address and the link target: a
2090
2100
  wikilink into a stub is refused, and a table cell listing one prints its name as
2091
2101
  plain text rather than as a link.
2092
2102
 
2093
- #### Two types are structural and exempt
2103
+ #### Structural classification does not exempt validation
2094
2104
 
2095
- `folder` and `homepage` have no body by design — a folder note's page is a
2096
- generated section index, and the homepage's is the site's front door. An empty
2097
- body on either keeps its address and resolves as **full**, because such a note is
2098
- complete.
2105
+ A `folder` note's page is a generated section index, and the homepage is the
2106
+ site's front door. The classification helpers retain their addresses and report
2107
+ **full** even when their bodies are empty. Validation still rejects an empty
2108
+ body for both types.
2099
2109
 
2100
2110
  Every type in the vocabulary declares whether an empty body suppresses its page,
2101
2111
  so adding a type means answering the question rather than inheriting a default.
@@ -4735,8 +4745,8 @@ the map for the pack it is writing and falls back to `default` when there is no
4735
4745
  such key, so a mistyped `journal:` files the folder wherever the default puts it
4736
4746
  — exactly the hierarchy the key was written to override, and silently.
4737
4747
 
4738
- **A folder note carries no prose.** It is structure, not content, so it produces
4739
- no documentation journal and takes no part in `docEntryTypes`.
4748
+ **A folder note requires a body**, like every typed note. Its body does not
4749
+ produce a documentation journal or take part in `docEntryTypes`.
4740
4750
  In a compiled Foundry journal, a wikilink to a folder prints its label as text:
4741
4751
  the folder can materialise in several packs and has no single compendium UUID.
4742
4752
 
@@ -42,9 +42,8 @@
42
42
  * because a `Folder` is a core Foundry document like a `JournalEntry` or a
43
43
  * `Scene`, not a system's.
44
44
  *
45
- * **A folder note carries no prose.** It is structure, not content, so it wants
46
- * no documentation journal and takes no part in `docEntryTypes` — the one
47
- * open decision, settled the way it recommended.
45
+ * **A folder note requires a body** under author validation. That body creates
46
+ * no documentation journal and takes no part in `docEntryTypes`.
48
47
  *
49
48
  * @module
50
49
  */
package/engine/index.mjs CHANGED
@@ -93,7 +93,7 @@ export * as noteState from "./note-state.mjs";
93
93
  /** What a draft note says about itself, in one sentence, on every surface. */
94
94
  export * as draftNotice from "./draft-notice.mjs";
95
95
 
96
- /** Whether every empty body is a stub on purpose, and how finished a tree is. */
96
+ /** Require written bodies on typed notes and report how finished a tree is. */
97
97
  export * as stubLint from "./stub-lint.mjs";
98
98
 
99
99
  /** What a place is next to and reachable from: the closed sets, and the checks. */
@@ -197,8 +197,8 @@ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
197
197
  *
198
198
  * Two types answer **false**, and both are structural: a `folder` note's page
199
199
  * is a generated section index and a `homepage`'s is the site's front door, so
200
- * each is complete with no body at all. An empty body on one of them keeps its
201
- * address and resolves as `full`.
200
+ * an empty body on one of them keeps its address and resolves as `full`.
201
+ * This is classification only: author validation requires a body for every type.
202
202
  *
203
203
  * @typedef {object} TypeVocabulary
204
204
  * @property {readonly DataFieldSpec[]} data - The `data:` keys, closed.
@@ -1413,8 +1413,7 @@ export const NOTE_VOCABULARY = Object.freeze({
1413
1413
  // document, so its address carries the `none` system segment and
1414
1414
  // everything it says is a `data` property.
1415
1415
  //
1416
- // It carries **no prose**: a folder
1417
- // is structure, not content, so it wants no documentation journal and takes
1416
+ // Its required body produces no documentation journal and takes
1418
1417
  // no part in `docEntryTypes`.
1419
1418
  folder: Object.freeze({
1420
1419
  stubbable: false,
@@ -12,34 +12,16 @@
12
12
  */
13
13
 
14
14
  /**
15
- * An empty body must be deliberate.
15
+ * Every typed note needs a nonempty body.
16
16
  *
17
- * An empty body is what makes a note a **stub**, and the whole scheme turns on
18
- * that being on purpose rather than something somebody forgot. So four rules
19
- * hold the state honest, and two more report what the rules cannot decide:
17
+ * A missing or whitespace-only body is an error, whether or not the note has
18
+ * a `draft` tag or a frontmatter description. Placeholder-only bodies remain
19
+ * errors. A nonempty body below twenty-five words receives a warning when it
20
+ * is not tagged `draft`; the author decides whether that short note is done.
20
21
  *
21
- * 1. **A stub states what it is.** With nothing in the body, the `description`
22
- * is the only sentence a reader gets and it is what every table and index
23
- * prints. An **error**.
24
- * 2. **A stub is not tagged `draft`.** A thing not started is not a thing in
25
- * progress. An **error**.
26
- * 3. **A body that renders to nothing is an abandoned draft, not a stub.** This
27
- * is what keeps the empty-body rule's severity honest: the softer cases are
28
- * *reported* rather than silently treated as one state or the other. An
29
- * **error**.
30
- * 4. **A short unmarked body may be a draft that forgot its marker.** A
31
- * **warning**, never an error — a note reading `See [[affiliation-meivor|Mëivōr]]`
32
- * says everything it has to say, and a corpus holds many of them.
33
- * 5. **A long-standing draft is a report, not a finding.** It is unfinished,
34
- * which the tag already says, so the oldest are listed and nobody is asked
35
- * to dismiss a warning they cannot clear.
36
- * 6. **The counts are reported**, per package and per type, so the ratio of
37
- * written to unwritten is visible on every run rather than discovered in a
38
- * year.
39
- *
40
- * **What this does not decide is what earns a body.** Whether a given village
41
- * deserves prose is a question about a setting, and it belongs in the
42
- * repository that ships it. The toolchain's job is to make promotion free.
22
+ * Counts and the oldest drafts remain reports. Classification and indexing
23
+ * still derive full, draft and stub states from the existing note records;
24
+ * validation reports missing content without changing those records.
43
25
  *
44
26
  * @module
45
27
  */
@@ -50,7 +32,7 @@ import path from "node:path";
50
32
  import { authoredFrontmatter, isNoteRecord, isStub, noteFile } from "./index-records.mjs";
51
33
  import { positionInFrontmatter, positionOfLiteral } from "./diagnostics.mjs";
52
34
  import { parseMarkdownFile } from "./helpers.mjs";
53
- import { bodyWordCount, isStubNote, placeholderBody } from "./note-state.mjs";
35
+ import { bodyWordCount, placeholderBody } from "./note-state.mjs";
54
36
  import { isDraftNote } from "./note-vocabulary.mjs";
55
37
 
56
38
  /**
@@ -67,16 +49,6 @@ export const SHORT_BODY_WORDS = 25;
67
49
  /** How many of the oldest drafts the report names. */
68
50
  const OLDEST_DRAFTS = 10;
69
51
 
70
- /**
71
- * Whether a note states a description.
72
- *
73
- * @param {Record<string, any>} fm - The note's authored frontmatter.
74
- * @returns {boolean} Whether `description` carries a sentence.
75
- */
76
- function hasDescription(fm) {
77
- return String(fm?.description ?? "").trim() !== "";
78
- }
79
-
80
52
  /**
81
53
  * Check one note's state, and say where the fault is.
82
54
  *
@@ -87,29 +59,14 @@ function checkNote({ fm, body, file, raw }) {
87
59
  const findings = [];
88
60
  const at = (key, value) => positionInFrontmatter(raw, key, value, { topLevel: true });
89
61
 
90
- if (isStubNote(fm, body)) {
91
- if (!hasDescription(fm)) {
92
- findings.push({
93
- file,
94
- ...at("type", fm.type),
95
- severity: "error",
96
- message:
97
- "this note has no body, so it is a stub — and a stub carries " +
98
- "a `description`, which is the only sentence a reader gets " +
99
- "and what every table listing it prints",
100
- });
101
- }
102
- if (isDraftNote(fm)) {
103
- findings.push({
104
- file,
105
- ...at("type", fm.type),
106
- severity: "error",
107
- message:
108
- "this note has no body and is tagged `draft`, and a thing " +
109
- "not started is not a thing in progress — remove the tag, " +
110
- "or write a body",
111
- });
112
- }
62
+ if (String(body ?? "").trim() === "") {
63
+ findings.push({
64
+ file,
65
+ ...at("type", fm.type),
66
+ severity: "error",
67
+ message:
68
+ "this note has no body — write a nonempty body, even when it is tagged `draft`",
69
+ });
113
70
  return findings;
114
71
  }
115
72
 
@@ -122,8 +79,7 @@ function checkNote({ fm, body, file, raw }) {
122
79
  severity: "error",
123
80
  message:
124
81
  `this note's body says only "${phrase}", so it publishes a page ` +
125
- `that tells a reader nothing — empty the body to make this a ` +
126
- `stub, or write it`,
82
+ `that tells a reader nothing — replace the placeholder with content`,
127
83
  });
128
84
  return findings;
129
85
  }
@@ -204,8 +160,8 @@ export function lintNoteStates(contentBase, { records, contentPackage } = {}) {
204
160
  if (state === "draft") drafts.push({ file, modified: fs.statSync(absPath).mtime });
205
161
 
206
162
  // Held to the rules only where there is a note to hold: a file that
207
- // declares no type declares nothing for them to read, and asking it for
208
- // a `description` would report vault scaffolding as content.
163
+ // declares no type declares nothing for them to read; requiring its
164
+ // body would report vault scaffolding as content.
209
165
  if (record.type) findings.push(...checkNote({ fm, body, file, raw }));
210
166
  }
211
167
 
package/githooks/pre-push CHANGED
@@ -4,7 +4,7 @@
4
4
  # container, over a clean export of HEAD — so a red GitHub check is something
5
5
  # you chose rather than something you discover.
6
6
  #
7
- # The steps are not listed here. `utils/ci-steps.mjs` reads them out of
7
+ # The steps are not listed here. `ci/ci-steps.mjs` reads them out of
8
8
  # `.github/workflows/build.yml`, because a hook holding its own copy of the
9
9
  # command list is a second statement of one thing, and the copy is the one that
10
10
  # goes stale — silently, since running four of five steps still exits 0.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.27.0",
3
+ "version": "22.28.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -182,6 +182,8 @@
182
182
  "scripts": {
183
183
  "test": "vitest run",
184
184
  "test:watch": "vitest",
185
+ "build": "node bin/package-build.mjs ci",
186
+ "deploy:npm": "node ci/publish-npm.mjs",
185
187
  "prepack": "run-s build:types build:asset-index",
186
188
  "build:types": "tsc -p tsconfig.dts.json",
187
189
  "build:asset-index": "node ci/emit-asset-index.mjs",
@@ -455,8 +455,8 @@ export type DataFieldSpec = {
455
455
  *
456
456
  * Two types answer **false**, and both are structural: a `folder` note's page
457
457
  * is a generated section index and a `homepage`'s is the site's front door, so
458
- * each is complete with no body at all. An empty body on one of them keeps its
459
- * address and resolves as `full`.
458
+ * an empty body on one of them keeps its address and resolves as `full`.
459
+ * This is classification only: author validation requires a body for every type.
460
460
  */
461
461
  export type TypeVocabulary = {
462
462
  /**