@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
@@ -47,18 +47,27 @@
47
47
  * type living in the SoHL registry would be unavailable to HM3 and to every HM3
48
48
  * module, which is most of the packages that need a homepage and nothing else.
49
49
  *
50
- * **Its address is the package's, not the note's.** A homepage publishes at
51
- * `/<contentPackage>/` because that is where the package is, so `name.full`,
52
- * `shortcode` and `id` decide nothing on it nothing here reads them, and
53
- * {@link HOMEPAGE_REFUSED_FIELDS} refuses them outright rather than leaving an
54
- * author to believe they worked (#53). It compiles into no document, so it carries
55
- * no compendium UUID and appears in no pack and in no link-manifest entry.
50
+ * **Addressed like every other note.** A homepage declares a `shortcode` —
51
+ * conventionally {@link HOMEPAGE_SHORTCODE} and publishes at its address,
52
+ * `/<package>/<type>-<shortcode>/`, written by the same rule as everything else
53
+ * (#182). It used to publish at `/<package>/` from a fixed destination, and
54
+ * that is why it refused `name` and `shortcode`: a URL derived from `name.full`
55
+ * while the destination did not, so `[[homepage-<shortcode>]]` resolved *green*
56
+ * to a page nothing wrote. A page's URL is its address now (#181), so the
57
+ * computed address is the published one and there is nothing left to refuse.
58
+ * The package's own `/<package>/` becomes a redirect its repository authors —
59
+ * see `CONTENT.md` — rather than a page this build writes.
60
+ *
61
+ * `id` is still refused, on ground the change does not touch: a homepage
62
+ * compiles into no document, so it carries no compendium UUID and appears in no
63
+ * pack and in no link-manifest entry.
56
64
  *
57
65
  * @module
58
66
  */
59
67
 
60
68
  import fs from "node:fs";
61
69
 
70
+ import { addressSlug } from "./content-address.mjs";
62
71
  import { matchAllOutsideCode } from "./code-fences.mjs";
63
72
  import { formatLocator, positionInFrontmatter } from "./diagnostics.mjs";
64
73
 
@@ -74,23 +83,44 @@ export const HOMEPAGE_TYPE = "homepage";
74
83
  *
75
84
  * Empty on purpose, and declared rather than omitted: a type with no vocabulary
76
85
  * and a type that is unknown are different findings, and only the second is an
77
- * authoring error. The whole envelope is the two top-level keys `type` and an
78
- * optional `title`; there is no game-system data on a page that compiles to no
79
- * document.
86
+ * authoring error. The whole envelope is the top-level keys `type` and
87
+ * `shortcode`, plus an optional `title` or `name`; there is no game-system data
88
+ * on a page that compiles to no document.
80
89
  *
81
90
  * @type {readonly import("./field-spec.mjs").FieldSpec[]}
82
91
  */
83
92
  export const HOMEPAGE_FIELDS = Object.freeze([]);
84
93
 
85
94
  /**
86
- * Where a homepage is written, relative to the package's site root.
95
+ * The shortcode a package landing conventionally takes.
87
96
  *
88
- * Hugo's section landing, because the page *is* the package's landing: the
89
- * package root is a section and this is its index.
97
+ * A **convention, not a rule.** The address only has to be unique within the
98
+ * package, which `(type, shortcode)` already guarantees, and nothing here knows
99
+ * better than an author what their landing is called. What the constant buys is
100
+ * one spelling shared by the diagnostic, the documentation and the six trees —
101
+ * so `[[homepage-root|…]]` is the same link in every package.
90
102
  *
91
103
  * @type {string}
92
104
  */
93
- export const HOMEPAGE_DESTINATION = "_index.md";
105
+ export const HOMEPAGE_SHORTCODE = "root";
106
+
107
+ /**
108
+ * The file a homepage is written to, relative to the package's site root.
109
+ *
110
+ * Its **address**, flat at the package root, and stated in the page's own `url`
111
+ * — the same separation every other page has since #181, where the directory
112
+ * decides the Hugo section and the front matter decides the URL. Flat rather
113
+ * than inside a `homepage/` section directory, because a homepage is not one of
114
+ * a kind: a section holding exactly one page would publish a landing at
115
+ * `/<package>/kb/homepage/` that nothing links to and nobody wrote.
116
+ *
117
+ * @param {object} fm - Parsed frontmatter.
118
+ * @returns {string} The destination filename, e.g. `homepage-root.md`.
119
+ * @throws {Error} When the note declares no shortcode, and so has no address.
120
+ */
121
+ export function homepageDestination(fm) {
122
+ return `${addressSlug(fm)}.md`;
123
+ }
94
124
 
95
125
  /**
96
126
  * Whether a note's frontmatter declares the homepage type.
@@ -103,58 +133,33 @@ export function isHomepage(fm) {
103
133
  }
104
134
 
105
135
  /**
106
- * The top-level fields a homepage refuses, and what each one would decide (#53).
107
- *
108
- * A note's URL derives from `name.full` and its identity from
109
- * `(type, shortcode)`. The homepage is the one page for which neither holds: it
110
- * publishes at `/<package>/`, fixed by the package id. An author fluent in the
111
- * conventions writes them here expecting exactly what they do everywhere else,
112
- * and gets none of it.
113
- *
114
- * **They were never inert, which is why ignoring them was the wrong answer.** A
115
- * `shortcode` puts the note in the address index and in the `dataview` link
116
- * universe, so `[[homepage-<shortcode>]]` resolves *green* to
117
- * `homepage/<slug>/`, an address derived from `name.full` and published by
118
- * nothing, because a homepage is written to {@link HOMEPAGE_DESTINATION} at the
119
- * package root. A build that reports a live link to a 404 is worse than one
120
- * that says nothing. It also inflates `content-build lint`'s address tally, so
121
- * the lint and the link manifest disagree about what the package publishes.
122
- *
123
- * **A named class, not an allow-list, and that boundary is the decision.** The
124
- * documented envelope is `type` plus an optional `title`, and `landing`,
125
- * `description` and `banner` are legitimate beside them — but a homepage's
126
- * frontmatter is *emitted into the published page*
136
+ * The top-level field a homepage refuses, and what it would decide (#53).
137
+ *
138
+ * **One field, where there used to be three.** `name` and `shortcode` were
139
+ * refused because a page's URL derived from `name.full` while a homepage's
140
+ * destination was fixed, so the address a `shortcode` computed named a page the
141
+ * site build never wrote. A page's URL is its address now (#181) and a homepage
142
+ * publishes at its own, so both fields decide exactly what they decide
143
+ * everywhere else and are permitted (#182).
144
+ *
145
+ * `id` is untouched by that, and stays: it is the Foundry document id a
146
+ * compendium UUID is built from, and a homepage compiles into no document.
147
+ *
148
+ * **A named class, not an allow-list, and that boundary is the decision.** A
149
+ * homepage's frontmatter is *emitted into the published page*
127
150
  * ({@link homepageFrontmatter}), so an unrecognised key is a Hugo or theme
128
151
  * parameter this build has never heard of and has no standing to refuse.
129
152
  * Rejecting unknown keys would make every new theme parameter wait on a
130
- * package-build release. What is refused is the specific class that makes a
131
- * false claim about *where this page is*.
153
+ * package-build release.
132
154
  *
133
- * `aliases` is deliberately not in the class: {@link homepageFrontmatter}
134
- * already drops it from every emitted page, with a reason of its own, so
135
- * authoring one is the same no-op it is on any other page rather than a wrong
136
- * belief about this one's address.
155
+ * `aliases` is deliberately not in the class: it is a **retired** field, refused
156
+ * on every note whatever its type (#180), so it is answered there rather than
157
+ * here.
137
158
  *
138
159
  * @type {ReadonlyMap<string, string>}
139
160
  */
140
161
  export const HOMEPAGE_REFUSED_FIELDS = Object.freeze(
141
162
  new Map([
142
- [
143
- "name",
144
- "`name` decides nothing on a `type: homepage` note: a page's slug " +
145
- "derives from `name.full`, and a homepage's destination is " +
146
- `fixed — it is written to \`${HOMEPAGE_DESTINATION}\` at the ` +
147
- "package's own address, `/<package>/`. Write `title:` for what " +
148
- "the page is called, and delete `name`",
149
- ],
150
- [
151
- "shortcode",
152
- "`shortcode` decides nothing on a `type: homepage` note: this " +
153
- "page's address is the package's own, `/<package>/`, fixed by " +
154
- "the package id. It is not ignored either — it puts the note " +
155
- "in the address index, so `[[homepage-<shortcode>]]` resolves " +
156
- "to a page the site build never writes. Delete it",
157
- ],
158
163
  [
159
164
  "id",
160
165
  "`id` decides nothing on a `type: homepage` note: it is the " +
@@ -166,30 +171,54 @@ export const HOMEPAGE_REFUSED_FIELDS = Object.freeze(
166
171
  );
167
172
 
168
173
  /**
169
- * The address-bearing fields one note authors, in the order it authored them.
174
+ * What the address rule says about one note's top-level fields.
170
175
  *
171
- * Authoring order rather than declaration order, so a caller emitting one
172
- * diagnostic per finding emits them top to bottom down the file the order a
173
- * reader and a compiler-output parser both expect.
176
+ * Two statements about the same thing, so they are made together: the field a
177
+ * homepage **owes** and the field it may **not** write.
174
178
  *
175
- * Presence is the whole test: `shortcode:` authored empty still says "this page
176
- * has an address of its own", and a value cannot make the claim true.
179
+ * The missing `shortcode` comes first, and is located at `type:` rather than at
180
+ * a key that is not there the `homepage` value is what makes the field
181
+ * required, it is a real position in the file, and inventing a `1:1` for an
182
+ * absent key would put the author on the opening fence. The refused fields
183
+ * follow in the order the note authored them, so a caller emitting one
184
+ * diagnostic per finding walks down the file.
177
185
  *
178
- * Returned without a locator, because the two things that would supply one
179
- * the raw note text and the position helper belong to the caller. This
180
- * mirrors {@link module:engine/retired-fields}, whose retired-field messages
181
- * are likewise positioned by whoever reports them.
186
+ * Presence is the whole test for a refused field, and absence-or-blank for the
187
+ * required one: `shortcode:` authored empty is no address, and a value cannot
188
+ * make `id` mean something on a page that compiles to no document.
189
+ *
190
+ * Each finding carries the `locator` key to position it at, because the two
191
+ * things that would resolve one — the raw note text and the position helper —
192
+ * belong to the caller. This mirrors {@link module:engine/retired-fields},
193
+ * whose retired-field messages are likewise positioned by whoever reports them.
182
194
  *
183
195
  * @param {object|null|undefined} fm - Parsed frontmatter.
184
- * @returns {Array<{key: string, message: string}>} One entry per field the note
185
- * authored, empty for any note that is not a homepage.
196
+ * @returns {Array<{field: string, locator: {key: string, literal?: string},
197
+ * message: string}>} One entry per finding, empty for any note that is not a
198
+ * homepage and declares nothing wrong.
186
199
  */
187
200
  export function checkHomepageAddressFields(fm) {
188
201
  if (!isHomepage(fm)) return [];
189
202
  const out = [];
203
+
204
+ const shortcode = typeof fm.shortcode === "string" ? fm.shortcode.trim() : "";
205
+ if (!shortcode) {
206
+ out.push({
207
+ field: "shortcode",
208
+ locator: { key: "type", literal: HOMEPAGE_TYPE },
209
+ message:
210
+ "a `type: homepage` note declares a `shortcode`, like every " +
211
+ "other note: it is addressed as `homepage-<shortcode>` and " +
212
+ "published at `/<package>/homepage-<shortcode>/`, which is " +
213
+ "where `[[homepage-<shortcode>|Text]]` lands. Write " +
214
+ `\`shortcode: ${HOMEPAGE_SHORTCODE}\` — the package landing is ` +
215
+ `\`homepage-${HOMEPAGE_SHORTCODE}\` in every package`,
216
+ });
217
+ }
218
+
190
219
  for (const key of Object.keys(fm)) {
191
220
  const message = HOMEPAGE_REFUSED_FIELDS.get(key);
192
- if (message) out.push({ key, message });
221
+ if (message) out.push({ field: key, locator: { key }, message });
193
222
  }
194
223
  return out;
195
224
  }
@@ -203,12 +232,16 @@ export function checkHomepageAddressFields(fm) {
203
232
  * - _None_ and the package serves nothing at `/<package>/`. That is the failure
204
233
  * #50 exists to prevent, and it is silent — the site build reports `wrote 0
205
234
  * homepage(s)` and exits 0.
206
- * - _Two_ and it serves a page nobody chose. Every homepage is written to the
207
- * same {@link HOMEPAGE_DESTINATION}, so the second overwrites the first and
208
- * the package's front page is decided by the order the walk happened to reach
209
- * the files in by *filename*, on a type whose whole point is that it is
210
- * routed by frontmatter. There is no "first wins" convention to fall back on,
211
- * so nothing here can pick the right one.
235
+ * - _Two_ and it serves a page nobody chose. **This is a cardinality rule, and
236
+ * since #182 it is only that.** It used to rest on the fixed destination
237
+ * every homepage shared the second overwrote the first so the address
238
+ * rule enforced it as a side effect. A homepage is written at its own address
239
+ * now, so two of them publish two pages and collide over nothing; the
240
+ * duplicate-address check catches only the pair that happen to share a
241
+ * shortcode, and says nothing at all about a `homepage-root` beside a
242
+ * `homepage-front`. Which of the two the redirect at `/<package>/` should
243
+ * name is a question nothing here can answer, and both being reachable is
244
+ * not an answer to it.
212
245
  *
213
246
  * Neither has a safe default, so neither is a warning. A warning is the right
214
247
  * severity for something a build can proceed past correctly, and a build that
@@ -270,9 +303,10 @@ export function checkHomepageCount(found, { contentBase, contentPackage }) {
270
303
  message:
271
304
  `duplicate \`type: homepage\` note, also declared by ` +
272
305
  `${others.join(", ")}; a package has one front page` +
273
- `${contentPackage ? `, at${address},` : ""} and every ` +
274
- `homepage is written to the same \`${HOMEPAGE_DESTINATION}\` — ` +
275
- `so the one the walk reaches last silently overwrites the rest`,
306
+ `${contentPackage ? `, at${address}` : ""}, and each of these ` +
307
+ `publishes at an address of its ownso nothing here can say ` +
308
+ `which one that address should redirect to. Keep one, and make ` +
309
+ `the rest ordinary notes`,
276
310
  };
277
311
  });
278
312
  }
@@ -325,24 +359,35 @@ export function homepageTitle(fm, config) {
325
359
  /**
326
360
  * The frontmatter a homepage publishes with.
327
361
  *
328
- * The note's own, plus the two derived values every emitted page carries: the
329
- * resolved `title`, and the package the build derived — no note declares one
362
+ * The note's own, plus the derived values every emitted page carries: the
363
+ * resolved `title`, the package the build derived — no note declares one
330
364
  * (`package:` is retired, #56) and the theme's breadcrumb partial reads
331
- * `.Params.package`.
365
+ * `.Params.package` — and its **address**.
366
+ *
367
+ * The address is stated as `url` for the same reason every other page states
368
+ * one (#181): Hugo publishes a page where its file sits unless told otherwise,
369
+ * and a homepage's file sits at the package's site root. `slug` is written
370
+ * beside it because it is the last segment of that address and Hugo's own key
371
+ * for one; it decides nothing while `url` is present, but a page carrying only
372
+ * `url` would report a slug Hugo had inferred from the filename.
332
373
  *
333
374
  * An authored `aliases` is dropped for the same reason it is on every other
334
- * page: Obsidian reads it as names a reader might call the note, Hugo reads it
335
- * as URL redirects, and passing it through would publish a redirect stub at
336
- * each one.
375
+ * page: Hugo reads it as URL redirects, so passing it through would publish a
376
+ * redirect stub at each one. The field is retired (#180) and refused before a
377
+ * build reaches here, which makes this a guard rather than a working path.
337
378
  *
338
379
  * @param {object} fm - The note's frontmatter.
339
380
  * @param {object} options - Options.
340
381
  * @param {string} options.contentPackage - The package this build publishes.
341
382
  * @param {string} options.title - The resolved title.
383
+ * @param {string} options.base - Where the package is served, with both
384
+ * slashes — `/<package>/`.
342
385
  * @returns {object} The frontmatter to write.
386
+ * @throws {Error} When the note declares no shortcode, and so has no address.
343
387
  */
344
- export function homepageFrontmatter(fm, { contentPackage, title }) {
345
- const data = { ...fm, package: contentPackage, title };
388
+ export function homepageFrontmatter(fm, { contentPackage, title, base }) {
389
+ const slug = addressSlug(fm);
390
+ const data = { ...fm, package: contentPackage, title, slug, url: `${base}${slug}/` };
346
391
  delete data.aliases;
347
392
  return data;
348
393
  }
package/engine/index.mjs CHANGED
@@ -77,10 +77,10 @@ export * as noteVocabulary from "./note-vocabulary.mjs";
77
77
 
78
78
  /** The shipped Foundry manifest: locating it, reading it, guarding its id. */
79
79
 
80
- /** The URL a content note is published at the one web-address rule. */
80
+ /** The one normalisation this build makes: prose to a URL-safe token. */
81
81
  export * as contentSlug from "./content-slug.mjs";
82
82
 
83
- /** Section and address derivation on top of {@link contentSlug}. */
83
+ /** Where a content note publishes: its section, and its `type-shortcode` URL. */
84
84
  export * as contentAddress from "./content-address.mjs";
85
85
 
86
86
  /** Whether a vendored manifest can still be addressed, not merely read. */
@@ -125,9 +125,6 @@ export * as wikilinks from "./wikilinks.mjs";
125
125
  /** What a `[[…]]` is, before either resolver decides where it points. */
126
126
  export * as wikilinkSyntax from "./wikilink-syntax.mjs";
127
127
 
128
- /** The alias namespace: what a note may be called, and who may claim a name. */
129
- export * as aliasIndex from "./alias-index.mjs";
130
-
131
128
  /** The address index a site build resolves its wikilinks against. */
132
129
  export * as siteIndex from "./site-index.mjs";
133
130
 
@@ -29,11 +29,20 @@
29
29
  * start, by {@link packageAddress}, and the emitting build's mount point is not
30
30
  * a fact it has to be told (#1465).
31
31
  *
32
+ * **An entry's `path` is derivable from the key it is filed under** (#181).
33
+ * `sohl-affliction-aconite` publishes at `affliction-aconite/`, because a page's
34
+ * URL *is* its address; nothing in it comes from a display name, so a rename
35
+ * moves no URL and no uniqueness check stands between the two. The field is
36
+ * still written rather than left for a consumer to compute, because a landing
37
+ * page is the one entry that is not derivable — it addresses its section under
38
+ * the configured mount — and because an absent `path` already means something
39
+ * else entirely (a package that publishes no pages).
40
+ *
32
41
  * **The address scheme is configuration, and it is shared with the site build.**
33
42
  * Where the content tree mounts inside the package and which note is a section's
34
43
  * landing page differ between repositories and are both load-bearing — `sohl`
35
- * records `kb/affliction/aconite/` and `thalorna` records
36
- * `affiliation/the-aerarium-imperii/`. Reading one setting here and in the page
44
+ * records `kb/rules/` for the landing of its rules section and `thalorna`
45
+ * records `affiliation/` for its own. Reading one setting here and in the page
37
46
  * emitter is what stops a manifest asserting an address the site does not
38
47
  * publish, which resolves at build time and 404s for the reader.
39
48
  *
@@ -54,8 +63,9 @@ import { canonicalKey, writeManifests } from "./kb-manifest.mjs";
54
63
  import { walkMarkdownTree } from "./helpers.mjs";
55
64
  import { compendiumUuid, packForType, pageUuid } from "./ids.mjs";
56
65
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
66
+ import { isHomepage } from "./homepage.mjs";
57
67
  import { assertNoDeclaredPackage } from "./note-package.mjs";
58
- import { assertNoDraftField } from "./retired-fields.mjs";
68
+ import { assertNoAliasesField, assertNoDraftField } from "./retired-fields.mjs";
59
69
  import { journalPageId, splitPages } from "./journals.mjs";
60
70
  import { routerFor } from "./pack-router.mjs";
61
71
  import { loadPackConfig } from "./pack-config.mjs";
@@ -222,14 +232,23 @@ export function collectManifestEntries(contentBase, ctx) {
222
232
  configured: ctx.contentPackage,
223
233
  });
224
234
  assertNoDraftField(fm, { file: rel, absPath });
235
+ assertNoAliasesField(fm, { file: rel, absPath });
225
236
  if (!fm.type || !fm.shortcode) continue;
237
+ // A homepage is addressed like every other note since #182, and a
238
+ // shortcode alone would now put it here. It stays out for the reason it
239
+ // always did, which that change does not touch: a manifest entry is how
240
+ // another package resolves a **document**, and a homepage compiles into
241
+ // none — the same ground `id` is refused on. A cross-package link to a
242
+ // package's front page is its bare `/<package>/` address, which needs
243
+ // no index.
244
+ if (isHomepage(fm)) continue;
226
245
 
227
246
  const base = path.basename(absPath);
228
247
  const name = fm.name?.full ?? path.basename(absPath, ".md");
229
248
 
230
249
  let address;
231
250
  try {
232
- address = packageAddress(fm, name, {
251
+ address = packageAddress(fm, {
233
252
  isReadme: base.toLowerCase() === "readme.md",
234
253
  scheme: ctx.scheme,
235
254
  });
@@ -206,6 +206,21 @@ const CHARGES = Object.freeze([
206
206
  * fishing village is a `village` that is `fishing`, and the single-valued field
207
207
  * this replaced had to spell it `Fishing Village` as a value of its own.
208
208
  */
209
+ /**
210
+ * The declared tag that marks a note as **unfinished** (#183).
211
+ *
212
+ * Named once and referenced from the declaration below, because a second
213
+ * spelling is how the two come apart: rename the tag in `DECLARED_TAGS` and a
214
+ * private copy elsewhere keeps matching the old word, silently.
215
+ *
216
+ * It is a **presentation** fact and nothing more. A draft note compiles,
217
+ * validates, publishes and resolves like any other; only a link *into* it
218
+ * renders marked. What it emphatically is not is the retired `draft:` field,
219
+ * whose entire effect was to move a note from published to unresolvable — see
220
+ * {@link draftRetiredMessage}.
221
+ */
222
+ export const DRAFT_TAG = "draft";
223
+
209
224
  export const DECLARED_TAGS = Object.freeze({
210
225
  /** What a place *is*. */
211
226
  placeKind: Object.freeze({
@@ -287,7 +302,7 @@ export const DECLARED_TAGS = Object.freeze({
287
302
  ]),
288
303
  }),
289
304
  /** A note's working state, which any note may carry. */
290
- state: Object.freeze({ types: null, tags: Object.freeze(["draft"]) }),
305
+ state: Object.freeze({ types: null, tags: Object.freeze([DRAFT_TAG]) }),
291
306
  });
292
307
 
293
308
  /**
@@ -302,6 +317,48 @@ export function declaredTags(type, groups = DECLARED_TAGS) {
302
317
  return Object.freeze(applies.flatMap((g) => g.tags));
303
318
  }
304
319
 
320
+ /**
321
+ * Whether a note carries a given tag, however the author wrote it.
322
+ *
323
+ * `tags:` is authored by hand and Obsidian is permissive about it: a single tag
324
+ * may be a scalar rather than a list, a value may carry the leading `#` it is
325
+ * written with in prose, and case and surrounding space are not significant.
326
+ * The spelling of the tag *itself* still is — a near miss is a near miss, and
327
+ * the frontmatter lint is what reports it; nothing here guesses.
328
+ *
329
+ * Reads `tags` and, as Dataview does, `tag` — the singular spelling Obsidian
330
+ * also accepts.
331
+ *
332
+ * @param {object|null|undefined} fm - Parsed frontmatter.
333
+ * @param {string} tag - The tag to look for, in its declared spelling.
334
+ * @returns {boolean} Whether the note carries it.
335
+ */
336
+ export function hasTag(fm, tag) {
337
+ const raw = fm?.tags ?? fm?.tag;
338
+ if (raw == null) return false;
339
+ const wanted = String(tag).toLowerCase();
340
+ for (const entry of Array.isArray(raw) ? raw : [raw]) {
341
+ if (typeof entry !== "string") continue;
342
+ if (entry.trim().replace(/^#/, "").toLowerCase() === wanted) return true;
343
+ }
344
+ return false;
345
+ }
346
+
347
+ /**
348
+ * Whether a note is tagged as an unfinished **draft** (#183).
349
+ *
350
+ * The one reader of {@link DRAFT_TAG}, so both builds ask the same question of
351
+ * the same field. Presentation only: a draft note is in the packs, in the
352
+ * manifest and on the site exactly as any other, and this decides nothing but
353
+ * whether a link into it renders marked.
354
+ *
355
+ * @param {object|null|undefined} fm - Parsed frontmatter.
356
+ * @returns {boolean} Whether the note carries the `draft` tag.
357
+ */
358
+ export function isDraftNote(fm) {
359
+ return hasTag(fm, DRAFT_TAG);
360
+ }
361
+
305
362
  export const NOTE_VOCABULARY = Object.freeze({
306
363
  /* ----- actors --------------------------------------------------- */
307
364
 
@@ -22,8 +22,9 @@
22
22
  * says what to write instead rather than which value to correct.
23
23
  *
24
24
  * `package:` is retired the same way and is refused from `note-package.mjs`,
25
- * where the concept it belonged to still lives. `draft:` has no such home —
26
- * there is no surviving concept it was part of — so it is refused here.
25
+ * where the concept it belonged to still lives. `draft:` and the top-level
26
+ * `aliases:` have no such home — there is no surviving concept either was part
27
+ * of — so they are refused here.
27
28
  *
28
29
  * **What `draft:` did (#69).** It excluded a note from the compiled packs, from
29
30
  * the link manifest and from a consuming site build. Nothing reported the
@@ -34,6 +35,20 @@
34
35
  * *unresolvable*, silently — and it also suppressed real build failures, since
35
36
  * a note the compilers never reached could not fail on the defects it carried.
36
37
  *
38
+ * **What `aliases:` did (#180).** It fed the alias index, which is what a bare
39
+ * `[[Alias]]` was looked up in. That form resolved to nothing anywhere in the
40
+ * corpus, while the collision rule guarding it folded in every note's
41
+ * `name.full` and so decided what a note could be named (#179). The form and
42
+ * the index are retired together, leaving the field with no reader at all.
43
+ *
44
+ * **`name.aliases` fed the same index and is nonetheless kept.** It is
45
+ * **reserved** — held for a use that does not exist yet — so it is the one
46
+ * field here that is neither retired nor read. Nothing consults it: no index,
47
+ * no resolver, no lint rule, no emitter. A note carrying one compiles,
48
+ * resolves and emits exactly as if it were absent, and `tests/name-aliases-
49
+ * reserved.test.ts` pins that equivalence so a future reader cannot be added
50
+ * by accident.
51
+ *
37
52
  * **A field retired in favour of another is a third case (#142).** `draft:` and
38
53
  * `package:` were retired outright: nothing replaced them, so no value made
39
54
  * writing one right and refusal was the only honest answer. A *renamed* field
@@ -73,8 +88,9 @@ export function draftRetiredMessage(file) {
73
88
  ". It excluded the note from the compiled packs, the link manifest " +
74
89
  "and the site, and no checker reported the exclusion, so every " +
75
90
  "wikilink into it read as a link to a note that does not exist. To " +
76
- "mark a note as unfinished, tag it `#draft` instead: the build " +
77
- "ignores tags, and a `FROM #draft` query still finds it"
91
+ "mark a note as unfinished, tag it `#draft` instead: the note still " +
92
+ "compiles and publishes, a link into it renders marked, and a " +
93
+ "`FROM #draft` query still finds it"
78
94
  );
79
95
  }
80
96
 
@@ -109,6 +125,97 @@ export function assertNoDraftField(fm, { file, absPath } = {}) {
109
125
  throw err;
110
126
  }
111
127
 
128
+ /**
129
+ * What a note declaring a top-level `aliases:` is told, in one place.
130
+ *
131
+ * Shared by the compile-time refusal and the frontmatter lint, because an
132
+ * author meets whichever of the two runs first and they should read the same.
133
+ * It says what the field fed and what to write instead, rather than which value
134
+ * to correct: no value makes declaring it right.
135
+ *
136
+ * **What it did (#180).** It was the authored half of the alias index — the
137
+ * namespace a bare `[[Alias]]` was looked up in. Across the three content trees
138
+ * not one bare link resolved through it, while the collision rule that kept it
139
+ * unambiguous folded in every note's `name.full` and so dictated what a note
140
+ * could be named (#179). The form is retired, so the list has no reader.
141
+ *
142
+ * **`name.aliases` is a different field and is not retired.** It fed the same
143
+ * index, but unlike the top-level list it is being kept — reserved, unread,
144
+ * and deliberately unmentioned by this message, which would otherwise tell an
145
+ * author to delete a field they are allowed to write. See
146
+ * {@link assertNoAliasesField}.
147
+ *
148
+ * @param {string} [file] - The note's path, named in the message. Omit it where
149
+ * the caller emits through a diagnostic, whose locator already starts the
150
+ * line — repeating it prints the path twice.
151
+ * @returns {string} The message, unpunctuated at the end as a finding is.
152
+ */
153
+ export function aliasesRetiredMessage(file) {
154
+ return (
155
+ "`aliases:` is a retired frontmatter field — delete it" +
156
+ (file ? ` — ${file}` : "") +
157
+ ". It listed names the bare `[[Alias]]` form could cite, and that " +
158
+ "form is retired: every wikilink is now an address, written " +
159
+ "`[[type-shortcode|Text]]`. Nothing else ever read the list"
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Refuse a note that declares a top-level `aliases:`.
165
+ *
166
+ * Presence is the whole test. `aliases: []` is as retired as a populated one —
167
+ * it reads as "this note claims no other names", a statement about a namespace
168
+ * that no longer exists.
169
+ *
170
+ * **The nested `name.aliases` is deliberately not refused.** Both spellings fed
171
+ * the retired alias index, and both lost their reader with it, but only the
172
+ * top-level one is retired: `name.aliases` is **reserved**, held for a use that
173
+ * does not exist yet. So it is neither refused nor read — no index consults it,
174
+ * no rule validates its contents, nothing derives from it, and nothing emits
175
+ * it. It rides in the note as inert data, and a note carrying one compiles,
176
+ * resolves and emits exactly as if it were absent.
177
+ *
178
+ * That distinction is the reason this checks `Object.hasOwn(fm, "aliases")`
179
+ * rather than resolving a dotted key: the top-level field is the whole subject,
180
+ * and reaching into `name` at all is the thing being avoided.
181
+ *
182
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
183
+ * could not be parsed.
184
+ * @param {object} [options] - Options.
185
+ * @param {string} [options.file] - The note's path, named in the message. Omit
186
+ * it where the caller emits through a diagnostic, which puts the locator at
187
+ * the start of the line already — repeating it prints the path twice.
188
+ * @param {string} [options.absPath] - The note's file on disk, read only on the
189
+ * failing path to locate the offending line and column. The position rides on
190
+ * the thrown error as `position`, for a caller that emits a diagnostic.
191
+ * @returns {void}
192
+ * @throws {Error} When the note declares a top-level `aliases`.
193
+ */
194
+ export function assertNoAliasesField(fm, { file, absPath } = {}) {
195
+ if (!declaresRetiredAliasesField(fm)) return;
196
+
197
+ const err = new Error(`${aliasesRetiredMessage(file)}.`);
198
+ // Anchored at column 1: a permitted `name.aliases` writes the same key,
199
+ // indented, and a finding about the retired top-level field must never
200
+ // open on it.
201
+ const position = locateFrontmatterKey(absPath, "aliases", undefined, { topLevel: true });
202
+ if (position) err.position = position;
203
+ throw err;
204
+ }
205
+
206
+ /**
207
+ * Whether a note declares the retired top-level `aliases:`.
208
+ *
209
+ * A nested `name.aliases` is **not** this field and never answers true here —
210
+ * see {@link assertNoAliasesField} for why the two part company.
211
+ *
212
+ * @param {object|null|undefined} fm - Parsed frontmatter.
213
+ * @returns {boolean} Whether the retired field is declared.
214
+ */
215
+ export function declaresRetiredAliasesField(fm) {
216
+ return Boolean(fm) && typeof fm === "object" && Object.hasOwn(fm, "aliases");
217
+ }
218
+
112
219
  /**
113
220
  * A frontmatter key's position in a note's file, or nothing.
114
221
  *
@@ -121,11 +228,15 @@ export function assertNoDraftField(fm, { file, absPath } = {}) {
121
228
  * @param {string} [value] - When given, prefer the occurrence whose line also
122
229
  * carries this text — so a finding about one entry of a block opens on that
123
230
  * entry rather than on the key that introduces it.
231
+ * @param {object} [options] - Options, forwarded to
232
+ * {@link positionInFrontmatter}.
233
+ * @param {boolean} [options.topLevel=false] - Require the key at column 1, so
234
+ * an identically named nested key cannot answer for it.
124
235
  * @returns {{line?: number, column?: number}|undefined} Spreadable position
125
236
  * fields, dropped rather than guessed when the file cannot be read or the key
126
237
  * cannot be found — as `formatDiagnostic` requires.
127
238
  */
128
- export function locateFrontmatterKey(absPath, key, value = undefined) {
239
+ export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel = false } = {}) {
129
240
  if (!absPath) return undefined;
130
241
  let raw;
131
242
  try {
@@ -133,7 +244,7 @@ export function locateFrontmatterKey(absPath, key, value = undefined) {
133
244
  } catch {
134
245
  return undefined;
135
246
  }
136
- const at = positionInFrontmatter(raw, key, value);
247
+ const at = positionInFrontmatter(raw, key, value, { topLevel });
137
248
  return at.line === undefined ? undefined : at;
138
249
  }
139
250