@heroiclands/package-build 10.0.1 → 11.1.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 (47) hide show
  1. package/CHANGELOG.md +375 -0
  2. package/CONTENT.md +234 -71
  3. package/MIGRATING.md +64 -0
  4. package/bin/content-build.mjs +79 -75
  5. package/content-config.mjs +28 -0
  6. package/docs/content-format.md +90 -67
  7. package/engine/base-compiler.mjs +7 -1
  8. package/engine/content-address.mjs +71 -18
  9. package/engine/content-format-check.mjs +1 -1
  10. package/engine/content-links.mjs +93 -112
  11. package/engine/content-lint.mjs +14 -10
  12. package/engine/content-slug.mjs +39 -105
  13. package/engine/diagnostics.mjs +16 -2
  14. package/engine/frontmatter-lint.mjs +161 -18
  15. package/engine/helpers.mjs +31 -68
  16. package/engine/homepage.mjs +131 -86
  17. package/engine/index.mjs +2 -5
  18. package/engine/manifest-emit.mjs +23 -4
  19. package/engine/note-vocabulary.mjs +58 -1
  20. package/engine/retired-fields.mjs +117 -6
  21. package/engine/site-build.mjs +182 -59
  22. package/engine/site-index.mjs +57 -102
  23. package/engine/web-wikilinks.mjs +183 -127
  24. package/engine/wikilink-syntax.mjs +174 -34
  25. package/engine/wikilinks.mjs +159 -117
  26. package/package.json +1 -1
  27. package/types/content-config.d.mts +24 -0
  28. package/types/engine/base-compiler.d.mts +1 -1
  29. package/types/engine/content-address.d.mts +46 -14
  30. package/types/engine/content-links.d.mts +13 -17
  31. package/types/engine/content-slug.d.mts +11 -48
  32. package/types/engine/diagnostics.d.mts +14 -1
  33. package/types/engine/frontmatter-lint.d.mts +27 -2
  34. package/types/engine/helpers.d.mts +4 -3
  35. package/types/engine/homepage.d.mts +96 -60
  36. package/types/engine/index.d.mts +0 -1
  37. package/types/engine/note-vocabulary.d.mts +43 -0
  38. package/types/engine/retired-fields.d.mts +78 -1
  39. package/types/engine/site-build.d.mts +70 -17
  40. package/types/engine/site-index.d.mts +19 -21
  41. package/types/engine/web-wikilinks.d.mts +29 -28
  42. package/types/engine/wikilink-syntax.d.mts +126 -40
  43. package/types/engine/wikilinks.d.mts +29 -24
  44. package/engine/abbreviations.mjs +0 -0
  45. package/engine/alias-index.mjs +0 -153
  46. package/types/engine/abbreviations.d.mts +0 -44
  47. package/types/engine/alias-index.d.mts +0 -122
@@ -72,6 +72,10 @@ import {
72
72
  import { renderItemFieldReference } from "../engine/field-reference.mjs";
73
73
  import { lintContentTree } from "../engine/content-lint.mjs";
74
74
  import { lintFrontmatter } from "../engine/frontmatter-lint.mjs";
75
+ // The sections this repository declares — the open set a `README` landing's
76
+ // `subType` names (#197). Read from the resolved configuration the site build
77
+ // renders those landings from, so neither can name a section the other does not.
78
+ import { declaredSections } from "../content-config.mjs";
75
79
  import { loadContentFormat } from "../engine/content-format.mjs";
76
80
  import {
77
81
  checkDeclaredFields,
@@ -103,6 +107,8 @@ import {
103
107
  formatUnaddressableFinding as formatUnaddressable,
104
108
  } from "../engine/site-build.mjs";
105
109
  import { auditLinks, buildLinkIndex, walkReachability } from "../engine/content-links.mjs";
110
+ // The one place a link finding is worded, shared with both builds (#184).
111
+ import { linkFindingMessage } from "../engine/wikilink-syntax.mjs";
106
112
  import {
107
113
  emitDiagnostic,
108
114
  positionInFrontmatter,
@@ -189,6 +195,26 @@ function reportFailure(err) {
189
195
  else log.error(message);
190
196
  }
191
197
 
198
+ /**
199
+ * A note's source text, or `""` when it cannot be read.
200
+ *
201
+ * Used only to turn a link finding into a position. A path that no longer
202
+ * resolves — a page a build generated, a tree walked from somewhere else —
203
+ * yields `""`, and {@link positionOfLiteral} then reports nothing, so the
204
+ * diagnostic drops the line and column rather than guessing them.
205
+ *
206
+ * @param {string|undefined} file - Absolute path to the note.
207
+ * @returns {string} The file's contents, or `""`.
208
+ */
209
+ function readRawNote(file) {
210
+ if (!file) return "";
211
+ try {
212
+ return fs.readFileSync(file, "utf8");
213
+ } catch {
214
+ return "";
215
+ }
216
+ }
217
+
192
218
  /**
193
219
  * The declaration sets this package ships, addressable by system id.
194
220
  *
@@ -714,6 +740,22 @@ function lintCommand() {
714
740
  // decides which vocabulary a tree is held to.
715
741
  vocabulary: NOTE_VOCABULARY,
716
742
  references: argv.references,
743
+ // How this repository addresses a section landing, and the
744
+ // sections that can exist — the open vocabulary a
745
+ // `README`'s `subType` is read against (#197, #200).
746
+ // Passed in for the same reason the schemas are: the
747
+ // linter checks a note against what it is handed.
748
+ //
749
+ // The types come from the **specification**, not from
750
+ // `schemas`: a landing's `subType` is an address, and a
751
+ // type the format declares is a section whether or not
752
+ // this build carries a schema to check its notes' fields
753
+ // with. Reading `schemas` here would refuse `Lore/README.md`
754
+ // for a reason that is not about addresses, and report one
755
+ // gap twice in two vocabularies.
756
+ landing: config.publish.address.landing,
757
+ types: [...loadContentFormat().types.keys()],
758
+ sections: declaredSections(config),
717
759
  });
718
760
 
719
761
  // What the builders emit, against what the receiving system
@@ -1022,8 +1064,7 @@ function linksCommand() {
1022
1064
  const {
1023
1065
  deadAnchors,
1024
1066
  deadAddresses,
1025
- deadAliases,
1026
- aliasCollisions,
1067
+ unlabelledLinks,
1027
1068
  frontmatterLinks,
1028
1069
  homepageLinks,
1029
1070
  usedManifest,
@@ -1039,62 +1080,19 @@ function linksCommand() {
1039
1080
  `heading in ${d.dest.rel} declares`,
1040
1081
  });
1041
1082
  }
1042
- // The pipe says the author meant an address (#131), so all
1043
- // three of these are errors — but they read differently
1044
- // because the corrections differ.
1045
- for (const d of deadAddresses) {
1083
+ // Every link is an address (#180) and every address must
1084
+ // resolve (#184), so all of these are errors — but they read
1085
+ // differently because the corrections differ. The wording comes
1086
+ // from the shared table, so the checker cannot describe a
1087
+ // defect differently from the build that also refuses it.
1088
+ for (const d of [...deadAddresses, ...unlabelledLinks]) {
1046
1089
  emitDiagnostic({
1047
1090
  file: d.note.file,
1048
1091
  ...positionOfLiteral(d.note.raw, d.text, d.occurrence),
1049
1092
  severity: "error",
1050
- message:
1051
- d.reason === "not-an-address" ?
1052
- `"${d.target}" is written as an address — the ` +
1053
- `"|" says so — but it is not one; write ` +
1054
- `[[type-shortcode|Text]], or drop the "|" to ` +
1055
- `name it as an alias within this note's type`
1056
- : d.reason === "unknown-type" ?
1057
- `address [[${d.target}]] names no known ` + `content type`
1058
- : `dead address [[${d.target}]] — no document ` + `has that identity`,
1059
- });
1060
- }
1061
- // An alias that names nothing may be a worldbuilding
1062
- // placeholder — a long-standing convention in the setting
1063
- // trees — so it is reported and does not fail the build. The
1064
- // ambiguous case is the collision below, reported at its
1065
- // claimants rather than here.
1066
- for (const d of deadAliases) {
1067
- emitDiagnostic({
1068
- file: d.note.file,
1069
- ...positionOfLiteral(d.note.raw, d.text, d.occurrence),
1070
- severity: "warning",
1071
- message:
1072
- d.ambiguous ?
1073
- `alias [[${d.target}]] is claimed by ` +
1074
- `${d.claimants.length} ${d.note.type} notes, ` +
1075
- `so it names none of them; address the ` +
1076
- `intended one as [[type-shortcode|Text]]`
1077
- : `unresolved alias [[${d.target}]] — no ` +
1078
- `${d.note.type} note claims that name`,
1093
+ message: linkFindingMessage(d),
1079
1094
  });
1080
1095
  }
1081
- // Reported once per claimant, at the claimant, because the
1082
- // note that merely cites an ambiguous alias is innocent (#13).
1083
- for (const c of aliasCollisions) {
1084
- const others = c.claimants.map((n) => n.rel).join(", ");
1085
- for (const claimant of c.claimants) {
1086
- emitDiagnostic({
1087
- file: claimant.file,
1088
- ...positionInFrontmatter(claimant.raw, "aliases", c.alias),
1089
- severity: "error",
1090
- message:
1091
- `alias "${c.alias}" is claimed by ` +
1092
- `${c.claimants.length} ${c.type} notes ` +
1093
- `(${others}), so [[${c.alias}]] names none of ` +
1094
- `them; rename all but one`,
1095
- });
1096
- }
1097
- }
1098
1096
  for (const f of frontmatterLinks) {
1099
1097
  emitDiagnostic({
1100
1098
  file: f.note.file,
@@ -1119,12 +1117,10 @@ function linksCommand() {
1119
1117
  });
1120
1118
  }
1121
1119
 
1122
- // Warnings are reported and do not fail: a dead alias may be a
1123
- // note not yet written, which is the placeholder convention.
1124
1120
  const failures =
1125
1121
  deadAnchors.length +
1126
1122
  deadAddresses.length +
1127
- aliasCollisions.reduce((n, c) => n + c.claimants.length, 0) +
1123
+ unlabelledLinks.length +
1128
1124
  frontmatterLinks.length +
1129
1125
  homepageLinks.length;
1130
1126
  if (failures) {
@@ -1132,21 +1128,14 @@ function linksCommand() {
1132
1128
  process.exitCode = 1;
1133
1129
  } else {
1134
1130
  log.info(
1135
- `${index.notes.length} notes: every anchor link lands ` +
1136
- `and every address resolves ` +
1137
- `(${usedManifest.size} cross-package reference(s) ` +
1138
- `via manifest), no alias claimed twice, no ` +
1131
+ `${index.notes.length} notes: every link is a labelled ` +
1132
+ `address, every anchor link lands and every ` +
1133
+ `address resolves (${usedManifest.size} ` +
1134
+ `cross-package reference(s) via manifest), no ` +
1139
1135
  `wikilink in frontmatter, every homepage address ` +
1140
1136
  `resolvable.`,
1141
1137
  );
1142
1138
  }
1143
- if (deadAliases.length) {
1144
- log.warn(
1145
- `${deadAliases.length} unresolved alias(es) — a bare ` +
1146
- `[[Name]] naming no note may be a placeholder, so ` +
1147
- `these are reported rather than failed.`,
1148
- );
1149
- }
1150
1139
  } catch (err) {
1151
1140
  reportFailure(err);
1152
1141
  process.exitCode = 1;
@@ -1275,16 +1264,13 @@ function siteCommand() {
1275
1264
  `page verbatim, and reaches the reader as brackets`,
1276
1265
  });
1277
1266
  }
1278
- for (const f of gates.slugErrors) {
1267
+ for (const f of gates.addressErrors) {
1279
1268
  emitDiagnostic({
1280
1269
  file: f.file,
1281
1270
  severity: "error",
1282
1271
  message: `cannot derive a URL: ${f.reason}`,
1283
1272
  });
1284
1273
  }
1285
- for (const c of gates.collisions) {
1286
- log.error(`${c.url} claimed by ${c.sources.join(", ")}`);
1287
- }
1288
1274
  for (const s of gates.staleManifests) {
1289
1275
  emitDiagnostic({
1290
1276
  file: path.join(loadPackConfig().paths.manifests, `${s.package}.json`),
@@ -1310,8 +1296,21 @@ function siteCommand() {
1310
1296
  for (const e of result.tableErrors) {
1311
1297
  log.error(`bad content table: ${e.reason} (${e.source})`);
1312
1298
  }
1299
+ // Reported the way the pack build reports the very same
1300
+ // finding: `file:line:column: error: message`, path first, and
1301
+ // the message from the shared table (#184). It used to be a
1302
+ // `log.error` whose timestamp prefix sat where a parser reads
1303
+ // the path from, and whose text named a `reason` code rather
1304
+ // than saying what to do — so one authored link produced a
1305
+ // machine-readable diagnostic from one build and prose from
1306
+ // another.
1313
1307
  for (const e of result.wikiErrors) {
1314
- log.error(`bad wikilink [[${e.target}]]: ${e.reason} (${e.file})`);
1308
+ emitDiagnostic({
1309
+ file: e.file,
1310
+ ...positionOfLiteral(readRawNote(e.file), e.link, e.occurrence),
1311
+ severity: "error",
1312
+ message: linkFindingMessage(e),
1313
+ });
1315
1314
  }
1316
1315
  if (result.tableErrors.length || result.wikiErrors.length) {
1317
1316
  process.exitCode = 1;
@@ -1319,11 +1318,16 @@ function siteCommand() {
1319
1318
  }
1320
1319
 
1321
1320
  if (result.manifests && !result.manifests.complete) {
1321
+ // Not a softening any more (#184): an address into one of
1322
+ // these packages fails like any other that resolves
1323
+ // nowhere. The warning names them so an author meeting that
1324
+ // failure knows the fix may be to vendor a manifest rather
1325
+ // than to correct a shortcode.
1322
1326
  log.warn(
1323
- `cross-package address checking is OFF — no manifest ` +
1324
- `for ${result.manifests.missing.join(", ")}. ` +
1325
- `Unresolved addresses are tolerated until every ` +
1326
- `package publishes one.`,
1327
+ `no link manifest vendored for ` +
1328
+ `${result.manifests.missing.join(", ")} — an ` +
1329
+ `address into one of those packages resolves ` +
1330
+ `nowhere and fails the build.`,
1327
1331
  );
1328
1332
  }
1329
1333
 
@@ -210,6 +210,34 @@ export function publishesContentPages(config) {
210
210
  return config.publish.site === "content";
211
211
  }
212
212
 
213
+ /**
214
+ * Every URL section this repository names, in declaration order (#197).
215
+ *
216
+ * A section is *declared* by describing it: `site.sections` for one whose
217
+ * landing this build generates, `site.readmeSections` for one whose landing is
218
+ * a `README`. Between them they are the open set of addresses a repository
219
+ * says it publishes under — which is what a `README` landing's `subType` names,
220
+ * since `sectionOf` reads that field as the section rather than as a genre.
221
+ *
222
+ * Read from the same two maps the site build renders each landing from, so the
223
+ * set a note is checked against and the set a landing is written from cannot
224
+ * come to disagree. A repository that describes no section declares none, and
225
+ * the answer is empty rather than a guess assembled from the tree.
226
+ *
227
+ * @param {{site: {sections: object, readmeSections: object}}} config - A
228
+ * resolved configuration.
229
+ * @returns {readonly string[]} The section names, deduplicated.
230
+ */
231
+ export function declaredSections(config) {
232
+ const site = config?.site ?? {};
233
+ return Object.freeze([
234
+ ...new Set([
235
+ ...Object.keys(site.sections ?? {}),
236
+ ...Object.keys(site.readmeSections ?? {}),
237
+ ]),
238
+ ]);
239
+ }
240
+
213
241
  /**
214
242
  * @typedef {"systems" | "modules"} PackageKind
215
243
  */
@@ -74,8 +74,8 @@ silently become a theme parameter, which is exactly why these cannot live
74
74
  together.
75
75
 
76
76
  **Tags are open, except the ones that classify.** `tags:` shares the top level's
77
- openness: a tag naming a theme, a region or a working state — `underworld`,
78
- `byzaria`, `draft` — is the author's own and this build has no opinion about it.
77
+ openness: a tag naming a theme or a region — `underworld`,
78
+ `byzaria`, `riverlands` — is the author's own and this build has no opinion about it.
79
79
  A tag that classifies the subject is different, because something queries it. A
80
80
  settlement tagged `village` appears in the list of villages and an untagged one
81
81
  does not, so `vilage` does not merely look wrong: it removes the note from an
@@ -91,6 +91,17 @@ is declared, so a near miss is a finding that names what you probably meant.
91
91
  | **being station** | `being` | `tradesfolk`, `common-folk`, `soldiery`, `administration`, `clergy`, `mages`, `underworld`, `dependents`, `guilded`, `unguilded` |
92
92
  | **state** | any | `draft` |
93
93
 
94
+ **`draft` is the one tag either build reads.** A note tagged `draft` exists so a
95
+ link into it is not dead, and a link into it renders marked — as
96
+ `<span class="sohl-draft-link" title="Draft — not yet written">…</span>` in a
97
+ compiled journal and on the website alike, with the appearance supplied by the
98
+ consuming system's stylesheet or the site theme. **Nothing else changes**: the
99
+ note compiles, validates, publishes and resolves exactly as any other, and it
100
+ stays in the packs, in the link manifest and on the site. That is what separates
101
+ the tag from the retired `draft:` field, whose whole effect was to move a note
102
+ from published to unresolvable without saying so — and which is refused, by name,
103
+ if you write it.
104
+
94
105
  **The group's scope is what makes the check work.** A group names the types it
95
106
  applies to, and a place's kinds are only ever checked on a place. Without that
96
107
  the rule is wrong on every note it touches: `azravan` on a faith, `barter` on an
@@ -235,49 +246,78 @@ resolution with nothing to say so.
235
246
  `type/shortcode` with a slash is the legacy form, still resolved so links written
236
247
  before the vault migrated do not silently die. A slash is _unconditionally_ an
237
248
  address separator — pipe or no pipe — so an unknown type before one is an error
238
- rather than a fallback to the alias index.
249
+ rather than something to guess at.
239
250
 
240
- #### The pipe decides how a target resolves
251
+ #### Every link is an address, and every link carries a label
241
252
 
242
- There are two namespaces, and the pipe says which one to consult:
253
+ There is one namespace, and the pipe is required:
243
254
 
244
255
  | written | resolved as | displays |
245
256
  | -------------------- | ----------- | -------------------------- |
246
- | `[[Alias]]` | an alias | the alias, as written |
247
257
  | `[[WikiLink\|]]` | an address | the target note's own name |
248
258
  | `[[WikiLink\|Text]]` | an address | `Text` |
249
-
250
- **No pipe means look this up in the alias index. A pipe means parse this as an
251
- address.** The two never compete, and neither falls back to the other: a failure
252
- in one namespace is a dead link, not an invitation to try the other.
253
-
254
- That is what makes positional parsing safe. Note names contain hyphens
255
- `Grukar-ahk` is a name, not a `Grukar` of type `ahk` and under a
256
- resolve-by-shape rule the reader would have to consult the type vocabulary before
257
- it dared split. Under the pipe rule it never faces the question: an unpiped
258
- target is taken whole, hyphens and all, and only a piped target is ever split.
259
-
260
- The empty label is not an oversight and not a way of writing no label. It says
261
- _address this target, and show whatever it calls itself_ — so a note renamed later
262
- takes its new name at every citation with no link edited. Omitting the pipe says
263
- something else entirely: the phrase in the brackets is the alias, and the alias is
264
- also what the reader sees.
265
-
266
- So the two are worth keeping distinct even though both display a name: one is a
267
- promise to follow the target, the other is a phrase the author chose.
268
-
269
- #### An alias resolves only within its own type
270
-
271
- A bare `[[awareness]]` in a `skill` note finds the `skill` whose alias is
272
- `awareness` — not an `affliction` that happens to share the name. The alias index
273
- is keyed by `(source note's type, alias)`, so the same word may be an alias in
274
- several types without colliding.
275
-
276
- Two notes of the **same** type claiming one alias is an error, and the report
277
- names the claimants rather than the note that merely cited them. An ambiguous
278
- alias never resolves to whichever was indexed first.
279
-
280
- #### In frontmatter, a link is a bare address never an alias
259
+ | `[[Name]]` | nothing | a finding |
260
+
261
+ **A link written without a label addresses nothing** (#180), and the correction
262
+ is always the same: write `[[type-shortcode|Text]]`.
263
+
264
+ The bare form used to name an **alias** a note's own display name, or one of
265
+ the names it listed in `aliases:` looked up within the citing note's type. It
266
+ was measured before it was retired, and the namespace was empty in practice:
267
+ across 8,305 wikilinks in three content trees, **not one** bare link resolved to
268
+ a note. What the index behind it did do was fold every note's `name.full` into
269
+ itself, so two notes of one type could not share a display name — a rules page
270
+ and a user-guide page both called "Gear" were a build failure whose every
271
+ available fix moved a published URL (#179).
272
+
273
+ The top-level `aliases:` that fed it is **retired** and refused. The nested
274
+ `name.aliases:` is **not**: it is reserved for a use that does not exist yet, so
275
+ it is permitted and read by nothing — no index, no resolver, no lint rule, no
276
+ derived address. A note carrying one behaves exactly as one without it.
277
+
278
+ Requiring the label is also what makes positional parsing safe. Note names
279
+ contain hyphens `Grukar-ahk` is a name, not a `Grukar` of type `ahk` — so a
280
+ target that does not parse as an address is reported as one that does not, rather
281
+ than split at an arbitrary place or quietly looked up somewhere else.
282
+
283
+ The **empty** label is not a way of writing no label. It says _address this
284
+ target, and show whatever it calls itself_ — so a note renamed later takes its new
285
+ name at every citation with no link edited. `[[x|]]` is labelled; `[[x]]` is not.
286
+
287
+ The link part may still be an anchor: `[[#slug|Text]]` addresses a section of the
288
+ page it is written on. It is the label that is required, not a target.
289
+
290
+ #### Every address resolves, and every build says so the same way
291
+
292
+ An address that names no note **fails the build** (#184) — in the link checker,
293
+ in the pack compilers and in the site build alike.
294
+
295
+ It was a warning in the checker and, in the site build, nothing at all while any
296
+ linkable package had no vendored manifest. The reasoning was that `[[Sunless
297
+ Vault]]` might be a placeholder for a note somebody meant to write. That was a
298
+ property of the **bare** form, which is retired, and the intent behind it has a
299
+ real spelling now: a note tagged `draft` exists, resolves, compiles and
300
+ publishes, and a link to it renders visibly marked (#183). So an address landing
301
+ nowhere is a typo or an omission, and both want fixing.
302
+
303
+ There are six ways a link can fail, and each is one **error** with one message
304
+ wherever it is met:
305
+
306
+ | finding | what it means | the fix |
307
+ | ---------------- | ---------------------------------------------- | ----------------------------------------- |
308
+ | `unlabelled` | no `\|`, so the link addresses nothing | write `[[type-shortcode\|Text]]` |
309
+ | `not-an-address` | labelled, but the target is not an address | write the address, not the name |
310
+ | `unknown-type` | qualified, but names no type this build knows | correct the type segment |
311
+ | `unresolved` | parses as an address; nothing publishes it | fix the shortcode, or vendor the manifest |
312
+ | `ambiguous` | more than one package publishes the short form | write `[[package-type-shortcode\|Text]]` |
313
+ | `unknown-anchor` | the address resolves; the `#section` does not | correct the anchor |
314
+
315
+ The vocabulary and the messages live in one module (`engine/wikilink-syntax.mjs`)
316
+ precisely because an author meets whichever build ran first. Three resolvers read
317
+ one authored link; they must not describe the same mistake in three ways, and
318
+ they must never disagree about whether it is a mistake at all.
319
+
320
+ #### In frontmatter, a link is a bare address
281
321
 
282
322
  A `WikiLink` **field** takes the address with no brackets:
283
323
 
@@ -292,17 +332,10 @@ not `[[hexhodai]]`. The field is declared as a `WikiLink`, so the schema already
292
332
  knows the value is an address and reads it as one; brackets would be punctuation
293
333
  the reader has to strip before it can do anything.
294
334
 
295
- **Aliases are not permitted here.** A frontmatter value is always parsed by the
296
- address grammar above, so a single-segment value such as `hexhodai` is a
297
- _shortcode_, not an alias the same spelling means different things in a
298
- frontmatter field and in body prose, and this is the rule that says which.
299
-
300
- The reason is that frontmatter is structure rather than prose. An alias is an
301
- authorial convenience for writing a sentence that reads well; a field value is a
302
- reference something else will compile against, and it should say exactly what it
303
- points at. There is also nowhere to put the pipe: the distinction body text draws
304
- with punctuation has no equivalent in a YAML scalar, so the region as a whole
305
- picks one namespace and keeps it.
335
+ A frontmatter value is parsed by the address grammar above, so a single-segment
336
+ value such as `hexhodai` is a _shortcode_. Frontmatter is structure rather than
337
+ prose: a field value is a reference something else will compile against, and it
338
+ should say exactly what it points at.
306
339
 
307
340
  **The field supplies the type.** Every `WikiLink` field declares the note type it
308
341
  targets — `seat` a `place`, `parents` an `affiliation`, `stations` a `lore` — so
@@ -339,15 +372,10 @@ So the ladder has one rung where the field helps and three where it only checks:
339
372
  a bare shortcode takes its type from the declaration, and every longer form
340
373
  states the type itself and is verified against it.
341
374
 
342
- This also settles the alias question on its own. Aliases resolve within the
343
- _source_ note's type, and a field almost always points at a different type — a
344
- being's `stations` are `lore` notes — so an alias in a field would be looked up
345
- in the wrong namespace even where one existed to find.
346
-
347
375
  This is enforced rather than merely preferred: the build walks every frontmatter
348
- value, reports each bracketed link it finds, each value that resolves only as an
349
- alias, and each value whose qualification contradicts its field. A successful run says so — _no wikilink in frontmatter_ is part
350
- of what `content-build links` reports when it passes.
376
+ value, reports each bracketed link it finds, and reports each value whose
377
+ qualification contradicts its field. A successful run says so — _no wikilink in
378
+ frontmatter_ is part of what `content-build links` reports when it passes.
351
379
 
352
380
  Brackets belong in prose, where a link sits inside a sentence and needs marking
353
381
  off from the words around it. A frontmatter value has nothing to be marked off
@@ -355,21 +383,16 @@ from.
355
383
 
356
384
  #### Not yet implemented
357
385
 
358
- Two rules in this section are settled but unbuilt, and describe the target rather
386
+ One rule in this section is settled but unbuilt, and describes the target rather
359
387
  than current behaviour:
360
388
 
361
389
  - **The `<system>` segment.** `readQualifier` reads package, type and shortcode;
362
390
  there is no system segment. A four-segment target today parses as
363
391
  `package-type-shortcode` with a hyphenated shortcode, or fails.
364
- - **The pipe rule.** Today the pipe affects only the _label_: resolution runs the
365
- same either way, trying the address first and falling back to the alias — so a
366
- target is read by shape rather than by punctuation, and an author cannot say
367
- which of the two they meant.
368
-
369
- The corpus is close to the rule already. Of 12,056 links, 10,413 are
370
- `[[type-shortcode|Label]]`, which is correct as written. Two authored links use
371
- an unpiped multi-segment target, and 69 use a pipe with a note name where an
372
- address belongs; those are the migration.
392
+ The corpus is close to the rule already. Of 12,056 links, 10,413 are
393
+ `[[type-shortcode|Label]]`, which is correct as written. Two authored links use
394
+ an unpiped multi-segment target, and 69 use a pipe with a note name where an
395
+ address belongs; those are the migration.
373
396
 
374
397
  ### What a note produces
375
398
 
@@ -77,7 +77,7 @@ import {
77
77
  } from "./helpers.mjs";
78
78
  import { emitDiagnostic } from "./diagnostics.mjs";
79
79
  import { assertNoDeclaredPackage } from "./note-package.mjs";
80
- import { assertNoDraftField } from "./retired-fields.mjs";
80
+ import { assertNoAliasesField, assertNoDraftField } from "./retired-fields.mjs";
81
81
  import { assertTypeNotRetired, packForType } from "./ids.mjs";
82
82
  import { carriesSystemBlock } from "./system-block.mjs";
83
83
  import { checkAuthoredSystemData, checkEmittedSystemData } from "./schema-check.mjs";
@@ -770,6 +770,11 @@ export class BasePackCompiler {
770
770
  // - `draft:` (#69): it excluded the note from the packs, the
771
771
  // manifest and the site, and no checker reported the links that
772
772
  // left dangling.
773
+ // - `aliases:` (#180): it fed the alias index, which the bare
774
+ // `[[Alias]]` form was looked up in; the form is retired, so the
775
+ // list has no reader left. The nested `name.aliases` is a
776
+ // different field and is **not** refused — it is reserved, and
777
+ // deliberately neither read nor validated.
773
778
  //
774
779
  // Both are reported and counted — never skipped, which is how a
775
780
  // tree naming a package nothing answers to used to compile zero
@@ -778,6 +783,7 @@ export class BasePackCompiler {
778
783
  try {
779
784
  assertNoDeclaredPackage(fm, { absPath });
780
785
  assertNoDraftField(fm, { absPath });
786
+ assertNoAliasesField(fm, { absPath });
781
787
  } catch (err) {
782
788
  stats.declined++;
783
789
  this.errorCount++;