@heroiclands/package-build 10.0.1 → 11.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 (44) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/CONTENT.md +218 -71
  3. package/MIGRATING.md +64 -0
  4. package/bin/content-build.mjs +59 -75
  5. package/docs/content-format.md +90 -67
  6. package/engine/base-compiler.mjs +7 -1
  7. package/engine/content-address.mjs +71 -18
  8. package/engine/content-format-check.mjs +1 -1
  9. package/engine/content-links.mjs +93 -112
  10. package/engine/content-lint.mjs +14 -10
  11. package/engine/content-slug.mjs +39 -105
  12. package/engine/diagnostics.mjs +16 -2
  13. package/engine/frontmatter-lint.mjs +26 -13
  14. package/engine/helpers.mjs +31 -68
  15. package/engine/homepage.mjs +131 -86
  16. package/engine/index.mjs +2 -5
  17. package/engine/manifest-emit.mjs +23 -4
  18. package/engine/note-vocabulary.mjs +58 -1
  19. package/engine/retired-fields.mjs +117 -6
  20. package/engine/site-build.mjs +182 -59
  21. package/engine/site-index.mjs +57 -102
  22. package/engine/web-wikilinks.mjs +183 -127
  23. package/engine/wikilink-syntax.mjs +174 -34
  24. package/engine/wikilinks.mjs +159 -117
  25. package/package.json +1 -1
  26. package/types/engine/base-compiler.d.mts +1 -1
  27. package/types/engine/content-address.d.mts +46 -14
  28. package/types/engine/content-links.d.mts +13 -17
  29. package/types/engine/content-slug.d.mts +11 -48
  30. package/types/engine/diagnostics.d.mts +14 -1
  31. package/types/engine/helpers.d.mts +4 -3
  32. package/types/engine/homepage.d.mts +96 -60
  33. package/types/engine/index.d.mts +0 -1
  34. package/types/engine/note-vocabulary.d.mts +43 -0
  35. package/types/engine/retired-fields.d.mts +78 -1
  36. package/types/engine/site-build.d.mts +70 -17
  37. package/types/engine/site-index.d.mts +19 -21
  38. package/types/engine/web-wikilinks.d.mts +29 -28
  39. package/types/engine/wikilink-syntax.d.mts +126 -40
  40. package/types/engine/wikilinks.d.mts +29 -24
  41. package/engine/abbreviations.mjs +0 -0
  42. package/engine/alias-index.mjs +0 -153
  43. package/types/engine/abbreviations.d.mts +0 -44
  44. package/types/engine/alias-index.d.mts +0 -122
@@ -103,6 +103,8 @@ import {
103
103
  formatUnaddressableFinding as formatUnaddressable,
104
104
  } from "../engine/site-build.mjs";
105
105
  import { auditLinks, buildLinkIndex, walkReachability } from "../engine/content-links.mjs";
106
+ // The one place a link finding is worded, shared with both builds (#184).
107
+ import { linkFindingMessage } from "../engine/wikilink-syntax.mjs";
106
108
  import {
107
109
  emitDiagnostic,
108
110
  positionInFrontmatter,
@@ -189,6 +191,26 @@ function reportFailure(err) {
189
191
  else log.error(message);
190
192
  }
191
193
 
194
+ /**
195
+ * A note's source text, or `""` when it cannot be read.
196
+ *
197
+ * Used only to turn a link finding into a position. A path that no longer
198
+ * resolves — a page a build generated, a tree walked from somewhere else —
199
+ * yields `""`, and {@link positionOfLiteral} then reports nothing, so the
200
+ * diagnostic drops the line and column rather than guessing them.
201
+ *
202
+ * @param {string|undefined} file - Absolute path to the note.
203
+ * @returns {string} The file's contents, or `""`.
204
+ */
205
+ function readRawNote(file) {
206
+ if (!file) return "";
207
+ try {
208
+ return fs.readFileSync(file, "utf8");
209
+ } catch {
210
+ return "";
211
+ }
212
+ }
213
+
192
214
  /**
193
215
  * The declaration sets this package ships, addressable by system id.
194
216
  *
@@ -1022,8 +1044,7 @@ function linksCommand() {
1022
1044
  const {
1023
1045
  deadAnchors,
1024
1046
  deadAddresses,
1025
- deadAliases,
1026
- aliasCollisions,
1047
+ unlabelledLinks,
1027
1048
  frontmatterLinks,
1028
1049
  homepageLinks,
1029
1050
  usedManifest,
@@ -1039,62 +1060,19 @@ function linksCommand() {
1039
1060
  `heading in ${d.dest.rel} declares`,
1040
1061
  });
1041
1062
  }
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) {
1063
+ // Every link is an address (#180) and every address must
1064
+ // resolve (#184), so all of these are errors — but they read
1065
+ // differently because the corrections differ. The wording comes
1066
+ // from the shared table, so the checker cannot describe a
1067
+ // defect differently from the build that also refuses it.
1068
+ for (const d of [...deadAddresses, ...unlabelledLinks]) {
1046
1069
  emitDiagnostic({
1047
1070
  file: d.note.file,
1048
1071
  ...positionOfLiteral(d.note.raw, d.text, d.occurrence),
1049
1072
  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`,
1073
+ message: linkFindingMessage(d),
1059
1074
  });
1060
1075
  }
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`,
1079
- });
1080
- }
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
1076
  for (const f of frontmatterLinks) {
1099
1077
  emitDiagnostic({
1100
1078
  file: f.note.file,
@@ -1119,12 +1097,10 @@ function linksCommand() {
1119
1097
  });
1120
1098
  }
1121
1099
 
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
1100
  const failures =
1125
1101
  deadAnchors.length +
1126
1102
  deadAddresses.length +
1127
- aliasCollisions.reduce((n, c) => n + c.claimants.length, 0) +
1103
+ unlabelledLinks.length +
1128
1104
  frontmatterLinks.length +
1129
1105
  homepageLinks.length;
1130
1106
  if (failures) {
@@ -1132,21 +1108,14 @@ function linksCommand() {
1132
1108
  process.exitCode = 1;
1133
1109
  } else {
1134
1110
  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 ` +
1111
+ `${index.notes.length} notes: every link is a labelled ` +
1112
+ `address, every anchor link lands and every ` +
1113
+ `address resolves (${usedManifest.size} ` +
1114
+ `cross-package reference(s) via manifest), no ` +
1139
1115
  `wikilink in frontmatter, every homepage address ` +
1140
1116
  `resolvable.`,
1141
1117
  );
1142
1118
  }
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
1119
  } catch (err) {
1151
1120
  reportFailure(err);
1152
1121
  process.exitCode = 1;
@@ -1275,16 +1244,13 @@ function siteCommand() {
1275
1244
  `page verbatim, and reaches the reader as brackets`,
1276
1245
  });
1277
1246
  }
1278
- for (const f of gates.slugErrors) {
1247
+ for (const f of gates.addressErrors) {
1279
1248
  emitDiagnostic({
1280
1249
  file: f.file,
1281
1250
  severity: "error",
1282
1251
  message: `cannot derive a URL: ${f.reason}`,
1283
1252
  });
1284
1253
  }
1285
- for (const c of gates.collisions) {
1286
- log.error(`${c.url} claimed by ${c.sources.join(", ")}`);
1287
- }
1288
1254
  for (const s of gates.staleManifests) {
1289
1255
  emitDiagnostic({
1290
1256
  file: path.join(loadPackConfig().paths.manifests, `${s.package}.json`),
@@ -1310,8 +1276,21 @@ function siteCommand() {
1310
1276
  for (const e of result.tableErrors) {
1311
1277
  log.error(`bad content table: ${e.reason} (${e.source})`);
1312
1278
  }
1279
+ // Reported the way the pack build reports the very same
1280
+ // finding: `file:line:column: error: message`, path first, and
1281
+ // the message from the shared table (#184). It used to be a
1282
+ // `log.error` whose timestamp prefix sat where a parser reads
1283
+ // the path from, and whose text named a `reason` code rather
1284
+ // than saying what to do — so one authored link produced a
1285
+ // machine-readable diagnostic from one build and prose from
1286
+ // another.
1313
1287
  for (const e of result.wikiErrors) {
1314
- log.error(`bad wikilink [[${e.target}]]: ${e.reason} (${e.file})`);
1288
+ emitDiagnostic({
1289
+ file: e.file,
1290
+ ...positionOfLiteral(readRawNote(e.file), e.link, e.occurrence),
1291
+ severity: "error",
1292
+ message: linkFindingMessage(e),
1293
+ });
1315
1294
  }
1316
1295
  if (result.tableErrors.length || result.wikiErrors.length) {
1317
1296
  process.exitCode = 1;
@@ -1319,11 +1298,16 @@ function siteCommand() {
1319
1298
  }
1320
1299
 
1321
1300
  if (result.manifests && !result.manifests.complete) {
1301
+ // Not a softening any more (#184): an address into one of
1302
+ // these packages fails like any other that resolves
1303
+ // nowhere. The warning names them so an author meeting that
1304
+ // failure knows the fix may be to vendor a manifest rather
1305
+ // than to correct a shortcode.
1322
1306
  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.`,
1307
+ `no link manifest vendored for ` +
1308
+ `${result.manifests.missing.join(", ")} — an ` +
1309
+ `address into one of those packages resolves ` +
1310
+ `nowhere and fails the build.`,
1327
1311
  );
1328
1312
  }
1329
1313
 
@@ -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++;
@@ -18,9 +18,17 @@
18
18
  * knowledgebase build renders the page, and the link manifest records the
19
19
  * address other packages link to. Stating it twice is how a manifest comes to
20
20
  * assert a URL that resolves at build time and 404s for the reader.
21
+ *
22
+ * **A page's URL is its address** — `<package>/<type>-<shortcode>/` (#181). It
23
+ * used to be derived from `name.full`, which made a display string load-bearing
24
+ * in three separate ways: a rename moved the URL and nothing redirected, two
25
+ * notes in one section could derive the same URL so a uniqueness check had to
26
+ * run, and a long name had to be abbreviated through a word table to keep the
27
+ * result short. `(type, shortcode)` is unique within a package by rule
28
+ * (`engine/content-lint.mjs`), so the address is **unique by construction** —
29
+ * there is no check to run, and no rename to survive.
21
30
  */
22
31
 
23
- import { contentSlug } from "./content-slug.mjs";
24
32
  // The scheme vocabulary is part of the configuration contract — a
25
33
  // repository names its scheme in `package-build.config.yaml` — so it is
26
34
  // declared beside the rest of that vocabulary rather than here, and this
@@ -49,22 +57,54 @@ export function sectionOf(fm) {
49
57
  }
50
58
 
51
59
  /**
52
- * A note's address below the knowledgebase mount, e.g. `affliction/aconite/`.
60
+ * The single path segment a note is addressed by: `type-shortcode`.
61
+ *
62
+ * Lowercased, so it is exactly the tail of the note's canonical key
63
+ * (`canonicalKey` in `engine/kb-manifest.mjs` lowercases too) — which is what
64
+ * makes a manifest entry's `path` derivable from the key it is filed under
65
+ * rather than transported beside it.
66
+ *
67
+ * The hyphen is a separator and never occurs inside a segment: a shortcode is
68
+ * `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN`, enforced by `content-lint.mjs`)
69
+ * and a type is a bare word. That is the same charset guarantee positional key
70
+ * parsing rests on, so the address and the key cannot disagree about where one
71
+ * ends and the other begins.
72
+ *
73
+ * @param {object} fm - Parsed frontmatter.
74
+ * @returns {string} The address segment, e.g. `weapongear-dagger`.
75
+ * @throws {Error} When the note declares no type or no shortcode — either way
76
+ * it has no address, which is reported rather than papered over: inventing
77
+ * one would publish a page nothing can link to and record a manifest entry
78
+ * pointing at it.
79
+ */
80
+ export function addressSlug(fm) {
81
+ const type = typeof fm?.type === "string" ? fm.type.trim() : "";
82
+ if (!type) {
83
+ throw new Error("note declares no type, so it has no address");
84
+ }
85
+ const shortcode = typeof fm?.shortcode === "string" ? fm.shortcode.trim() : "";
86
+ if (!shortcode) {
87
+ throw new Error(
88
+ `note declares no shortcode, so it has no address — a page is ` +
89
+ `addressed as "${type}-<shortcode>"`,
90
+ );
91
+ }
92
+ return `${type}-${shortcode}`.toLowerCase();
93
+ }
94
+
95
+ /**
96
+ * A note's address below the knowledgebase mount, e.g. `affliction-aconite/`.
53
97
  *
54
98
  * A `README.md` **is** its section's landing page rather than a page within it,
55
- * so it addresses the section itself and has no slug of its own.
99
+ * so it addresses the section itself and has no address of its own.
56
100
  *
57
101
  * @param {object} fm - Parsed frontmatter.
58
- * @param {string} name - The note's display name; the slug derives from it
59
- * (#1278), never from the shortcode, which is identity rather than
60
- * presentation.
61
102
  * @param {boolean} isReadme - Whether the file is a `README.md`.
62
- * @returns {string} The section-relative address, with a trailing slash.
63
- * @throws {Error} When the name yields no usable slug.
103
+ * @returns {string} The mount-relative address, with a trailing slash.
104
+ * @throws {Error} When the note has no address.
64
105
  */
65
- export function contentAddress(fm, name, isReadme) {
66
- const sec = sectionOf(fm);
67
- return isReadme ? `${sec}/` : `${sec}/${contentSlug(name)}/`;
106
+ export function contentAddress(fm, isReadme) {
107
+ return isReadme ? `${sectionOf(fm)}/` : `${addressSlug(fm)}/`;
68
108
  }
69
109
 
70
110
  /**
@@ -92,17 +132,30 @@ function landingOf(fm, isReadme, landing) {
92
132
  }
93
133
 
94
134
  /**
95
- * A note's address relative to its **package**, e.g. `kb/affliction/aconite/`.
135
+ * A note's address relative to its **package**, e.g. `affliction-aconite/`.
96
136
  *
97
137
  * This is the form the link manifest records and the site build emits pages at,
98
138
  * and it is one function because those two must agree — a manifest asserting an
99
139
  * address the site does not publish resolves at build time and 404s for the
100
140
  * reader, which is the failure this module exists to prevent.
101
141
  *
142
+ * **The prefix does not apply to a page's own address.** `prefix` says where the
143
+ * content tree *mounts inside the package* — where its section directories and
144
+ * their landing pages live — and a landing page is addressed by that mount
145
+ * (`kb/rules/`). An ordinary page is addressed by `(type, shortcode)`, which is
146
+ * a package-wide identity and takes no mount: `sohl` publishes
147
+ * `/sohl/affliction-aconite/` while its section landings stay at
148
+ * `/sohl/kb/affliction/`. The `type-` half is what keeps that flat namespace
149
+ * clear of the package's fixed mounts — `/<package>/` for the landing,
150
+ * `/<package>/api/` for generated API docs, neither of which contains a hyphen
151
+ * or names a type.
152
+ *
153
+ * **The section still decides where the *file* is written**, which is why a
154
+ * note without one still has no address: Hugo derives a section from a page's
155
+ * directory rather than from its URL, so a page with nowhere to be filed is a
156
+ * page with no section landing, no `.CurrentSection` and no per-section layout.
157
+ *
102
158
  * @param {object} fm - Parsed frontmatter.
103
- * @param {string} name - The note's display name; a page slug derives from it
104
- * (#1278), never from the shortcode, which is identity rather than
105
- * presentation.
106
159
  * @param {object} [options] - Options.
107
160
  * @param {boolean} [options.isReadme] - Whether the file is a `README.md`.
108
161
  * @param {{prefix?: string, landing?: string}} [options.scheme] - The
@@ -110,11 +163,11 @@ function landingOf(fm, isReadme, landing) {
110
163
  * @returns {string} The package-relative address, with a trailing slash and no
111
164
  * leading one.
112
165
  * @throws {Error} When the note has no address — no section, a landing page
113
- * naming no section, or a name yielding no usable slug. Each is a note that
166
+ * naming no section, or no shortcode to be addressed by. Each is a note that
114
167
  * is not published, and inventing an address for one would put a dead entry
115
168
  * in the manifest.
116
169
  */
117
- export function packageAddress(fm, name, { isReadme = false, scheme } = {}) {
170
+ export function packageAddress(fm, { isReadme = false, scheme } = {}) {
118
171
  const { prefix, landing } = { ...DEFAULT_ADDRESS_SCHEME, ...scheme };
119
172
  if (!LANDING_RULES.includes(landing)) {
120
173
  throw new Error(
@@ -133,5 +186,5 @@ export function packageAddress(fm, name, { isReadme = false, scheme } = {}) {
133
186
  if (typeof sec !== "string" || !sec) {
134
187
  throw new Error(`type "${fm.type}" has no section`);
135
188
  }
136
- return `${prefix}${sec}/${contentSlug(name)}/`;
189
+ return `${addressSlug(fm)}/`;
137
190
  }
@@ -269,7 +269,7 @@ function nearest(key, candidates) {
269
269
  * @type {ReadonlySet<string>}
270
270
  */
271
271
  export const NOTE_LEVEL_KEYS = Object.freeze(
272
- new Set(["id", "type", "subType", "shortcode", "description", "tags", "name", "aliases"]),
272
+ new Set(["id", "type", "subType", "shortcode", "description", "tags", "name"]),
273
273
  );
274
274
 
275
275
  /** Whether a value is a plain object a block could be written as. */