@heroiclands/package-build 22.13.1 → 22.14.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.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ Content notes accept the documented top-level fields, with clear errors for other keys. Formatting keeps those fields in order and writes YAML collections as short lines or full blocks.
8
+
3
9
  ## 22.13.1
4
10
 
5
11
  ### Patch Changes
@@ -287,26 +287,49 @@ infoboxes:
287
287
 
288
288
  ### Frontmatter has three regions
289
289
 
290
- A note's frontmatter divides into three parts, and the difference matters because
291
- only one of them is open:
290
+ A HeroicLands note has opening YAML frontmatter with nonempty `shortcode` and
291
+ `type` values. Other Markdown files are ordinary Markdown. A note's frontmatter
292
+ has three regions:
292
293
 
293
- | region | describes | unknown keys |
294
- | -------------------- | ---------------------------------------------------------------------------------------------- | -------------------------- |
295
- | **top level** | the note as a published artefact — `id`, `type`, `subType`, `shortcode`, `description`, `tags` | **passed through to Hugo** |
296
- | **`data:`** | the subject itself — system-agnostic, specific to the note type | **an error** |
297
- | **`sohl:` / `hm3:`** | the subject as one system's documents | **an error** |
294
+ | Region | Describes | Unknown keys |
295
+ | -------------------- | ------------------------------------------------------- | ------------ |
296
+ | **Top level** | The note's identity, name, description, and publication | An error |
297
+ | **`data:`** | The subject itself, shared by systems | An error |
298
+ | **`hm3:` / `sohl:`** | The subject as one system's documents | An error |
298
299
 
299
- **Top level is deliberately open.** Every key is copied into the generated web
300
- page's front matter, so an unrecognised key is a Hugo or theme parameter this
301
- build has no standing to refuse. `description` is the everyday case: it is not a
302
- document field at all, it is the page's description.
300
+ The complete top-level allowlist, in its required order, is `shortcode`, `name`,
301
+ `type`, `subType`, `description`, `tags`, `data`, `hm3`, `sohl`. Omit fields that
302
+ do not apply. The first fields of a typical note look like this:
303
303
 
304
- **`data:` is deliberately closed.** It holds the type-specific facts about the
304
+ ```yaml
305
+ ---
306
+ shortcode: example
307
+ name: { full: Example }
308
+ type: lore
309
+ subType: concept
310
+ description: A short page description.
311
+ tags: [draft]
312
+ data: {}
313
+ sohl: {}
314
+ ---
315
+ ```
316
+
317
+ Any other top-level key is an error. To keep an author-only note beside the
318
+ content, comment out its YAML lines with `#`. Comments remain valid YAML text
319
+ and do not become data in the JSONL index, generated pages, or Foundry documents.
320
+ There is no custom site metadata. `description` supplies the page's short
321
+ description; `tags` carries publication and classification tags.
322
+
323
+ Arrays and objects use one-line YAML flow form when the complete line is under
324
+ 100 characters. At 100 characters or more, they use full block form. A multiline
325
+ flow collection is formatted into one of those forms; folded collections are
326
+ not used. `content-build format --write` applies this rule and the top-level
327
+ order without changing the Markdown body.
328
+
329
+ **`data:` is closed.** It holds the type-specific facts about the
305
330
  subject — a weapon's weight, an affliction's transmission, a being's species —
306
- and every note type declares which keys it may carry. A misspelled key there is a
307
- finding that names the key you meant; the same misspelling at top level would
308
- silently become a theme parameter, which is exactly why these cannot live
309
- together.
331
+ and every note type declares which keys it may carry. A misspelled key is a
332
+ finding in either region.
310
333
 
311
334
  Every note type may put a pinned document id in `data.id`, a shared compendium
312
335
  route in `data.pack`, a shared folder Address in `data.packFolder`, and source
@@ -314,8 +337,7 @@ details in `data.harnworld`. A system block's `pack` or `packFolder` overrides
314
337
  the shared value for that system. A `being` may also describe its social profile
315
338
  in `data.social`.
316
339
 
317
- **Tags are open, except the ones that classify.** `tags:` shares the top level's
318
- openness: a tag naming a theme or a region — `underworld`,
340
+ **Tags are open, except the ones that classify.** A tag naming a theme or a region — `underworld`,
319
341
  `byzaria`, `riverlands` — is the author's own and this build has no opinion about it.
320
342
  A tag that classifies the subject is different, because something queries it. A
321
343
  settlement tagged `village` appears in the list of villages and an untagged one
@@ -465,14 +487,11 @@ in-block spelling, and `hm3.system.gender` is the native destination.
465
487
  The native destination wins, followed by the in-block value and the shared
466
488
  source. Field declarations name both source and destination explicitly.
467
489
 
468
- **A field whose spelling means something else at the note level has no shared
469
- source.** The fallback assumes the two vocabularies agree about what a name
470
- means, and they do not always: a note's top-level `title` is the heading its page
471
- publishes under, while an `affiliation` item's `system.title` is the style of
472
- address an office carries. Where they diverge, the field declares what the
473
- top-level key means instead, and the top level stops being read for it — leaving
474
- `<system>.system.<field>` and the legacy in-block position, which describe the
475
- document rather than the note. Native species is independent of the shared lore Address in `data.species`.
490
+ **A field without a shared source reads its system declaration.** An
491
+ `affiliation` item's `system.title` is the style of address an office carries;
492
+ the note's heading reads `name.full`. The field reads
493
+ `<system>.system.<field>` and the in-block position, which describe the
494
+ document. Native species is independent of the shared lore Address in `data.species`.
476
495
  `subType` has the same meaning at both levels.
477
496
 
478
497
  **Some of a system's fields are runtime state, and a note may not write any of
@@ -1080,78 +1099,20 @@ identity across one pins its `id`, which is what the pin is for. One diagnostic
1080
1099
  narrows — `content-build` tells a **rename** from a **withdrawal** by matching
1081
1100
  document ids across releases, and for an unpinned note both sides now move
1082
1101
  together. It never reports a _wrong_ successor, and it stays exact for a pinned
1083
- note; for the rest, the note **declares** the rename (below).
1102
+ note; for the rest, the diff reports the vacated address.
1084
1103
 
1085
1104
  **A note with no address gets no id, and no document.** `type` and `shortcode`
1086
1105
  are what a note is addressed by, so a note missing either cannot be filed and
1087
1106
  the build refuses it by that name rather than by a missing `id:`.
1088
1107
 
1089
- #### Declaring a rename
1090
-
1091
- A note names the shortcode it used to be published under:
1092
-
1093
- ```yaml
1094
- type: weapongear
1095
- shortcode: Taburi
1096
- renamedFrom: Tabri
1097
- ```
1098
-
1099
- `(type, shortcode)` is a **published interface** — every satellite that declares
1100
- `itemCatalog: true` assembles its beings out of those addresses — so renaming a
1101
- shortcode breaks other repositories, and `content-build addresses diff` exists to
1102
- say so before a release does. To be useful it has to name where the address
1103
- _went_, and since ids are derived it can no longer work that out for an unpinned
1104
- note: both sides of the match move together, and the rename reads as a
1105
- withdrawal.
1106
-
1107
- **Pinning an `id` needs foresight; a declaration needs only hindsight.** A pin
1108
- has to be written _before_ the rename, by an author who does not yet know they
1109
- will make one. An author who has just renamed a shortcode knows exactly what the
1110
- old one was, and that is the only moment anyone does — so this is the key to
1111
- reach for, and `id:` stays what it is for: keeping a document's identity across
1112
- the rename, which is a different question from explaining it.
1113
-
1114
- **It takes one shortcode or a list**, because renames chain: the diff runs
1115
- against a released baseline, and a shortcode may have been renamed more than once
1116
- since. List every name the baseline might still know it by.
1117
-
1118
- ```yaml
1119
- shortcode: Taburin
1120
- renamedFrom:
1121
- - Tabri
1122
- - Taburi
1123
- ```
1124
-
1125
- **It is transient.** Once every baseline a build is compared against post-dates
1126
- the rename, the declaration has nothing left to say and should be deleted. That
1127
- is what separates it from an `id:` pin, which is permanent.
1128
-
1129
- **One key per note, at the top level**, however many systems the note compiles
1130
- into: a shortcode is the note's rather than a system block's, so a note carrying
1131
- `sohl:` and `hm3:` blocks compiles two documents that share one shortcode, and
1132
- one declaration covers both.
1133
-
1134
- **What a declaration changes is what the diagnostic can say, not what it says
1135
- about you.** A finding reports which of the two joins it had, because they are
1136
- not equally checkable — a matched id is a fact a reader can verify in both
1137
- artefacts, while a declaration is the author's word:
1138
-
1139
- ```text
1140
- since sohl@0.8.2, weapongear:Tabri is no longer published; the note now
1141
- published as weapongear:Taburi declares it was renamed from Tabri. Every
1142
- package that resolves weapongear:Tabri breaks when it moves past sohl@0.8.2
1143
- ```
1144
-
1145
- A rename that is neither pinned nor declared is still reported as a
1146
- **withdrawal**. Nothing infers a successor from a similar-looking string: a wrong
1147
- one sends the reader to the wrong fix, which is worse than saying nothing.
1108
+ #### Address changes
1148
1109
 
1149
- `content-lint` holds a declaration to the same rules a current address is held
1150
- to. An entry must be a well-formed shortcode, must not be the note's own, and
1151
- must name an address the package actually **vacated** — an entry naming an
1152
- address some note still publishes is refused, as are two notes claiming one
1153
- predecessor, since an address had one holder and so has one successor. A repeat
1154
- of the same entry is a warning; the declaration still works.
1110
+ `(type, shortcode)` is a published interface. Other packages can link to it or
1111
+ use it as an Item model, so changing either part requires those references to
1112
+ change too. `content-build addresses diff` compares the package with a released
1113
+ baseline and reports addresses whose published documents are absent. A pinned
1114
+ `data.id` preserves document identity across a shortcode change; it does not
1115
+ keep the old address available for links.
1155
1116
 
1156
1117
  #### The compendium folder
1157
1118
 
@@ -2760,9 +2721,8 @@ It declares a `shortcode` — conventionally `root` — because that is what a
2760
2721
  link is written with: `[[homepage-root|Text]]` is an ordinary wikilink, and it
2761
2722
  resolves to `/<package>/`. The shortcode names the page in links; its address
2762
2723
  is the package root. The page heading reads `name.full` and defaults to
2763
- `packageBuild.manifest.title`; a
2764
- `description` and a `banner` are read by the theme's hero; any other
2765
- top-level key is passed through to the page as a theme parameter.
2724
+ `packageBuild.manifest.title`. `description` supplies the page's short
2725
+ description.
2766
2726
 
2767
2727
  The page is its body, published verbatim: no wikilink is resolved on it and
2768
2728
  no table expanded, so its links are markdown links, package-relative
@@ -2770,9 +2730,8 @@ no table expanded, so its links are markdown links, package-relative
2770
2730
  written here and is not generated anywhere — it is a `doc` note carrying a
2771
2731
  content table, linked from this page like any other.
2772
2732
 
2773
- Two top-level keys are refused, because neither decides anything on a page:
2774
- `id`, the Foundry document id a compendium UUID is built from, and `landing`,
2775
- a card block — the homepage is a page with a body, rendered as one.
2733
+ The homepage uses the same top-level allowlist as every addressed note. It has
2734
+ no Foundry document id; a pinned `data.id` has no role on this type.
2776
2735
 
2777
2736
  ### type: vehicle
2778
2737
 
@@ -2998,14 +2957,10 @@ mapping for a field no schema declares is the drift these tables exist to catch.
2998
2957
  likewise absent here: they are filled on an embedded membership, never from a
2999
2958
  catalogue note's `data:`.
3000
2959
 
3001
- **`system.title` is not the note's `title`.** The two are unrelated quantities
3002
- that share a spelling. A note's top-level `title` is _the title of the note_ —
3003
- the heading its page is published under; an affiliation's `system.title` is _the
3004
- style of address the office carries_, Ajaw or Warden, which a being holds by
3005
- virtue of its rank. So the top-level key is **not** a shared source for this
3006
- field, and a note that writes one is stating its own heading and nothing else
3007
- . Author the style of address on the membership — the `system.title` of the
3008
- entry in a being's `sohl.items` — or, on a catalogue note that genuinely carries
2960
+ **`system.title` is the style of address the office carries**, Ajaw or Warden,
2961
+ which a being holds by virtue of its rank. The note's page heading reads
2962
+ `name.full`. Author the style of address on the membership — the `system.title`
2963
+ of the entry in a being's `sohl.items` — or, on a catalogue note that carries
3009
2964
  one, at `sohl.system.title`.
3010
2965
 
3011
2966
  ### type: affliction
@@ -55,6 +55,7 @@
55
55
  */
56
56
 
57
57
  import { isAddressTuple } from "./address.mjs";
58
+ import { authoredNoteKeys, NOTE_TOP_LEVEL_KEY_SET } from "./note-frontmatter.mjs";
58
59
  import { AddressEntries } from "./address-values.mjs";
59
60
  import { authoredFields, readsLegacyKey } from "./field-spec.mjs";
60
61
  import {
@@ -1443,6 +1444,24 @@ export function lintNote(
1443
1444
  });
1444
1445
  }
1445
1446
 
1447
+ for (const key of authoredNoteKeys(raw())) {
1448
+ if (NOTE_TOP_LEVEL_KEY_SET.has(key)) continue;
1449
+ const position = positionInFrontmatter(raw(), key, undefined, { topLevel: true });
1450
+ if (
1451
+ findings.some(
1452
+ (finding) => finding.severity === "error" && finding.line === position.line,
1453
+ )
1454
+ ) {
1455
+ continue;
1456
+ }
1457
+ findings.push({
1458
+ file: note.file,
1459
+ ...position,
1460
+ severity: "error",
1461
+ message: `unknown top-level frontmatter key ${JSON.stringify(key)}; use shortcode, name, type, subType, description, tags, data, hm3, or sohl`,
1462
+ });
1463
+ }
1464
+
1446
1465
  // The type's charset, before anything that looks the type up. A
1447
1466
  // hyphenated type is unaddressable, and every lookup below would report it
1448
1467
  // as a type nobody declared — true, but not the reason, and it would send
@@ -0,0 +1,100 @@
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
+ import YAML from "yaml";
15
+ import { NOTE_TOP_LEVEL_KEYS } from "./note-frontmatter.mjs";
16
+
17
+ const MAX_FLOW_LINE = 99;
18
+
19
+ /**
20
+ * Format one addressed note's YAML without changing its Markdown body.
21
+ * Invalid YAML remains the YAML linter's finding.
22
+ *
23
+ * @param {string} markdown - The complete Markdown source.
24
+ * @returns {string} The source with canonical frontmatter.
25
+ */
26
+ export function formatNoteFrontmatter(markdown) {
27
+ const match = markdown.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)([\s\S]*)$/);
28
+ if (!match) return markdown;
29
+ const [, opening, source, closing, body] = match;
30
+ const document = YAML.parseDocument(source, { keepSourceTokens: true });
31
+ if (document.errors.length || !YAML.isMap(document.contents)) return markdown;
32
+ const root = document.contents;
33
+ const values = Object.fromEntries(
34
+ root.items.map((pair) => [String(pair.key?.value), pair.value]),
35
+ );
36
+ if (!nonempty(values.shortcode) || !nonempty(values.type)) return markdown;
37
+
38
+ const order = new Map(NOTE_TOP_LEVEL_KEYS.map((key, index) => [key, index]));
39
+ const rank = (key) => order.get(key) ?? NOTE_TOP_LEVEL_KEYS.length;
40
+ root.items.sort((left, right) => {
41
+ const a = String(left.key?.value);
42
+ const b = String(right.key?.value);
43
+ const difference = rank(a) - rank(b);
44
+ return difference;
45
+ });
46
+ for (const pair of root.items) {
47
+ const key = String(pair.key?.value);
48
+ formatCollection(pair.value, key.length + 2, 0);
49
+ }
50
+ const formatted = document.toString({ lineWidth: 0, flowCollectionPadding: false }).trimEnd();
51
+ return `${opening}${formatted}${closing}${body}`;
52
+ }
53
+
54
+ function nonempty(node) {
55
+ return YAML.isScalar(node) && typeof node.value === "string" && node.value.trim() !== "";
56
+ }
57
+
58
+ /** Format nested collections before testing the enclosing line. */
59
+ function formatCollection(node, prefix, depth) {
60
+ if (!YAML.isMap(node) && !YAML.isSeq(node)) return;
61
+ if (YAML.isMap(node)) {
62
+ for (const pair of node.items) {
63
+ formatCollection(
64
+ pair.value,
65
+ 2 * (depth + 1) + String(pair.key?.value).length + 2,
66
+ depth + 1,
67
+ );
68
+ }
69
+ } else {
70
+ for (const item of node.items) formatCollection(item, 2 * (depth + 1) + 2, depth + 1);
71
+ }
72
+ if (hasBlockScalar(node) || hasAlias(node)) {
73
+ node.flow = false;
74
+ return;
75
+ }
76
+ node.flow = true;
77
+ const candidate = new YAML.Document(node)
78
+ .toString({
79
+ lineWidth: 0,
80
+ flowCollectionPadding: false,
81
+ })
82
+ .trimEnd();
83
+ if (candidate.includes("\n") || Array.from(candidate).length + prefix > MAX_FLOW_LINE) {
84
+ node.flow = false;
85
+ }
86
+ }
87
+
88
+ function hasBlockScalar(node) {
89
+ if (YAML.isScalar(node)) return node.type === "BLOCK_LITERAL" || node.type === "BLOCK_FOLDED";
90
+ if (YAML.isMap(node)) return node.items.some((pair) => hasBlockScalar(pair.value));
91
+ if (YAML.isSeq(node)) return node.items.some(hasBlockScalar);
92
+ return false;
93
+ }
94
+
95
+ function hasAlias(node) {
96
+ if (YAML.isAlias(node)) return true;
97
+ if (YAML.isMap(node)) return node.items.some((pair) => hasAlias(pair.value));
98
+ if (YAML.isSeq(node)) return node.items.some(hasAlias);
99
+ return false;
100
+ }
@@ -0,0 +1,33 @@
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
+ * SPDX-License-Identifier: GPL-3.0-or-later
6
+ */
7
+
8
+ import YAML from "yaml";
9
+
10
+ /** The complete ordered top-level vocabulary for addressed content notes. */
11
+ export const NOTE_TOP_LEVEL_KEYS = Object.freeze([
12
+ "shortcode",
13
+ "name",
14
+ "type",
15
+ "subType",
16
+ "description",
17
+ "tags",
18
+ "data",
19
+ "hm3",
20
+ "sohl",
21
+ ]);
22
+
23
+ /** Membership test for the top-level vocabulary. */
24
+ export const NOTE_TOP_LEVEL_KEY_SET = new Set(NOTE_TOP_LEVEL_KEYS);
25
+
26
+ /** Return authored keys from an opening YAML frontmatter map. */
27
+ export function authoredNoteKeys(markdown) {
28
+ const source = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
29
+ if (source === undefined) return [];
30
+ const document = YAML.parseDocument(source);
31
+ if (document.errors.length || !YAML.isMap(document.contents)) return [];
32
+ return document.contents.items.map((pair) => String(pair.key?.value));
33
+ }
@@ -33,6 +33,7 @@
33
33
 
34
34
  import fs from "node:fs";
35
35
  import path from "node:path";
36
+ import { formatNoteFrontmatter } from "./note-format.mjs";
36
37
 
37
38
  import {
38
39
  MARKDOWNLINT_CONFIG,
@@ -202,7 +203,7 @@ export async function checkFormatting(root, opts = {}) {
202
203
  let formatted = source;
203
204
  let converged = false;
204
205
  for (let pass = 0; pass < MAX_FORMAT_PASSES; pass += 1) {
205
- const next = await prettier.format(formatted, options);
206
+ const next = formatNoteFrontmatter(await prettier.format(formatted, options));
206
207
  if (next === formatted) {
207
208
  converged = true;
208
209
  break;
@@ -236,7 +237,7 @@ export async function checkFormatting(root, opts = {}) {
236
237
  continue;
237
238
  }
238
239
 
239
- if (!(await prettier.check(source, options))) {
240
+ if (formatNoteFrontmatter(await prettier.format(source, options)) !== source) {
240
241
  findings.push({
241
242
  file,
242
243
  severity: "error",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.13.1",
3
+ "version": "22.14.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",
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Format one addressed note's YAML without changing its Markdown body.
3
+ * Invalid YAML remains the YAML linter's finding.
4
+ *
5
+ * @param {string} markdown - The complete Markdown source.
6
+ * @returns {string} The source with canonical frontmatter.
7
+ */
8
+ export function formatNoteFrontmatter(markdown: string): string;
@@ -0,0 +1,6 @@
1
+ /** Return authored keys from an opening YAML frontmatter map. */
2
+ export function authoredNoteKeys(markdown: any): string[];
3
+ /** The complete ordered top-level vocabulary for addressed content notes. */
4
+ export const NOTE_TOP_LEVEL_KEYS: readonly string[];
5
+ /** Membership test for the top-level vocabulary. */
6
+ export const NOTE_TOP_LEVEL_KEY_SET: Set<string>;