@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
@@ -24,23 +24,20 @@
24
24
  * scheme, the section a note is filed under, whether developer docs are part of
25
25
  * the site at all — those genuinely differ, and the two builds differ on all
26
26
  * three. So this takes *entries that already know their own URL* and does the
27
- * part that is the same everywhere: index them, index their aliases, merge the
28
- * foreign packages in, and report what cannot be addressed unambiguously.
27
+ * part that is the same everywhere: index them, merge the foreign packages in,
28
+ * and report what cannot be addressed unambiguously.
29
29
  *
30
- * **Three key spaces, one map.**
30
+ * **Two key spaces, one map**, and both are addresses. `section/slug` and
31
+ * `type/shortcode` are unique by construction, so they always resolve.
32
+ * `type/shortcode` is the authored form; the canonical
33
+ * `package-type-shortcode` is set alongside it, which is what a cross-package
34
+ * link and every merged foreign entry use (#1499).
31
35
  *
32
- * - `section/slug` and `type/shortcode` are unique by construction, so they
33
- * always resolve. `type/shortcode` is the authored form; the canonical
34
- * `package-type-shortcode` is set alongside it, which is what a cross-package
35
- * link and every merged foreign entry use (#1499).
36
- * - A bare name, filename, or slug is a **collision-aware fallback**: a key
37
- * that would map to two different pages is dropped and remembered, so
38
- * `[[Name]]` on it fails the build rather than silently picking one. The
39
- * author disambiguates with `[[section/slug|Label]]`.
40
- * - Aliases are indexed **scoped to their type**, which is what makes a bare
41
- * `[[Shock]]` resolvable when "Shock" is both a rules page and a trauma item.
42
- * Two notes *of the same type* sharing a name poison it, and the author
43
- * writes `[[type/shortcode|Text]]`.
36
+ * **A page's *name* is not a key** (#180). It was, as one of a set of
37
+ * collision-aware fallbacks a bare `[[Name]]` was looked up in which is what
38
+ * made two pages of one type forbidden from sharing a display name (#179). The
39
+ * bare form is retired, so the fallbacks answer nothing and the constraint they
40
+ * imposed is gone with them.
44
41
  *
45
42
  * **It reports rather than exits.** A build script owns its diagnostics and its
46
43
  * exit code; this returns what it found. That is the same rule the rest of the
@@ -54,9 +51,8 @@ import path from "node:path";
54
51
  import { canonicalKey, readCanonicalKey } from "./kb-manifest.mjs";
55
52
  import { hasDocEntry } from "./item-docs.mjs";
56
53
  import { contentPackage } from "./content-package.mjs";
57
- // The alias namespace: what a page may be called, and what happens when two
58
- // pages of one type claim one name (#131).
59
- import { aliasesOf, indexAliases } from "./alias-index.mjs";
54
+ // The declared tag vocabulary (#172), which is where `draft` is stated.
55
+ import { isDraftNote } from "./note-vocabulary.mjs";
60
56
 
61
57
  /**
62
58
  * One page the site will publish, as the index needs to see it.
@@ -79,11 +75,13 @@ import { aliasesOf, indexAliases } from "./alias-index.mjs";
79
75
  * The resolved index and everything a wikilink resolver reads beside it.
80
76
  *
81
77
  * @typedef {object} SiteIndex
82
- * @property {Map<string, {url: string, name?: string}>} index Address → page.
83
- * @property {Set<string>} ambiguous Keys claimed by two pages, and so
84
- * deliberately absent from `index`.
85
- * @property {Map<string, {url: string, name?: string}>} typeAlias `type|alias`.
86
- * @property {Set<string>} typeCollide Type-scoped aliases claimed twice.
78
+ * @property {Map<string, {url: string, name?: string, draft?: boolean}>} index
79
+ * Address page. `draft` says the page
80
+ * carries the `draft` tag, which marks a
81
+ * link *into* it (#183).
82
+ * @property {Set<string>} ambiguous Short addresses claimed by two
83
+ * packages, and so deliberately absent
84
+ * from `index`.
87
85
  * @property {Set<string>} contentTypes Every type the resolver should read as
88
86
  * an address qualifier, local and foreign.
89
87
  * @property {Set<string>} sections Section names, lowercased.
@@ -95,31 +93,6 @@ import { aliasesOf, indexAliases } from "./alias-index.mjs";
95
93
  * build failure; the caller reports it.
96
94
  */
97
95
 
98
- /**
99
- * Add a collision-aware fallback key.
100
- *
101
- * First writer wins *until* a second, different page claims the key — at which
102
- * point the key is removed and blacklisted, so neither page answers to it. That
103
- * is deliberate: resolving to whichever note happened to be walked first is a
104
- * silently wrong link, and a failed build is not.
105
- *
106
- * @param {Map<string, object>} index - The index being built.
107
- * @param {Set<string>} collide - Keys already found ambiguous.
108
- * @param {string} key - The candidate key, in any case.
109
- * @param {{url: string}} value - The page it would resolve to.
110
- */
111
- function addFallback(index, collide, key, value) {
112
- const k = String(key).toLowerCase();
113
- if (collide.has(k)) return;
114
- const cur = index.get(k);
115
- if (cur && cur.url !== value.url) {
116
- index.delete(k);
117
- collide.add(k);
118
- } else if (!cur) {
119
- index.set(k, value);
120
- }
121
- }
122
-
123
96
  /**
124
97
  * Merge the packages this build does not publish into the local index.
125
98
  *
@@ -131,14 +104,18 @@ function addFallback(index, collide, key, value) {
131
104
  * The short `type/shortcode` form is merged too, because a bare `[[doc-xyz]]`
132
105
  * carries no package and must still find a foreign note when exactly one
133
106
  * package publishes that address. Claimed by two, it is genuinely ambiguous and
134
- * the author writes the qualified form the same rule the type-scoped aliases
135
- * use, one level out. **Local wins**: a live build is authoritative and a
107
+ * the author writes the qualified form. **Local wins**: a live build is
108
+ * authoritative and a
136
109
  * vendored manifest can only be staler, so a short key the local tree already
137
110
  * claims is left alone.
138
111
  *
139
112
  * @param {Map<string, object>} index - The local index, mutated.
140
113
  * @param {Map<string, {package: string, type?: string}>} foreignIndex - Merged in.
141
- * @returns {{key: string, package: string}[]} Addresses claimed twice.
114
+ * @returns {{conflicts: {key: string, package: string}[],
115
+ * ambiguous: Set<string>}} The addresses two packages both claim outright,
116
+ * and the short `type/shortcode` forms two foreign packages claim — those are
117
+ * left out of the index, so a resolver can say *ambiguous* rather than
118
+ * *nothing answers*.
142
119
  */
143
120
  function mergeForeign(index, foreignIndex) {
144
121
  const conflicts = [];
@@ -166,7 +143,7 @@ function mergeForeign(index, foreignIndex) {
166
143
  for (const [key, value] of short) {
167
144
  if (!index.has(key)) index.set(key, value);
168
145
  }
169
- return conflicts;
146
+ return { conflicts, ambiguous };
170
147
  }
171
148
 
172
149
  /**
@@ -182,9 +159,6 @@ function mergeForeign(index, foreignIndex) {
182
159
  */
183
160
  export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
184
161
  const index = new Map();
185
- const ambiguous = new Set();
186
- /** One entry per page, fed to {@link indexAliases} in a pass of its own. */
187
- const aliasEntries = [];
188
162
  const contentTypes = new Set();
189
163
  const sections = new Set();
190
164
  const refIndex = new Map();
@@ -195,16 +169,22 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
195
169
  const ownPackage = contentPackage();
196
170
  const packages = new Set(ownPackage ? [ownPackage] : []);
197
171
 
198
- // `section/slug` is unique by construction; the rest are fallbacks.
172
+ // `section/slug` is unique by construction. A page's name, filename and
173
+ // bare slug were indexed here too, as collision-aware fallbacks the bare
174
+ // `[[Name]]` form looked up; that form is retired and nothing consults
175
+ // them, so they are gone and with them the rule that two pages of a type
176
+ // may not share a name (#179, #180).
199
177
  for (const e of entries) {
200
178
  sections.add(String(e.sec).toLowerCase());
201
- const value = { url: e.url, name: e.name };
202
- index.set(`${e.sec}/${e.slug}`.toLowerCase(), value);
203
- addFallback(index, ambiguous, e.name, value);
204
- if (!e.isReadme) {
205
- addFallback(index, ambiguous, path.basename(e.base, ".md"), value);
206
- }
207
- addFallback(index, ambiguous, e.slug, value);
179
+ // `draft` rides on every key a page is addressable by, because a link
180
+ // into a draft note renders marked whichever of them the author wrote
181
+ // (#183). It decides nothing about resolution: the page is indexed and
182
+ // published as any other.
183
+ index.set(`${e.sec}/${e.slug}`.toLowerCase(), {
184
+ url: e.url,
185
+ name: e.name,
186
+ draft: isDraftNote(e.fm),
187
+ });
208
188
  }
209
189
 
210
190
  // A foreign package may use a type this build has never seen. Seeding those
@@ -225,7 +205,7 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
225
205
  // The corollary is that a conflict can only be reported against the keys
226
206
  // that exist at this point — the addressing ones, `section/slug` and the
227
207
  // bare fallbacks — which is precisely the overlap worth refusing.
228
- const conflicts = mergeForeign(index, foreignIndex);
208
+ const { conflicts, ambiguous } = mergeForeign(index, foreignIndex);
229
209
 
230
210
  for (const e of entries) {
231
211
  // A page with no type or shortcode — a developer doc — is addressable
@@ -233,7 +213,7 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
233
213
  if (e.kind !== "content") continue;
234
214
  const type = String(e.fm.type).toLowerCase();
235
215
  contentTypes.add(type);
236
- const value = { url: e.url, name: e.name };
216
+ const value = { url: e.url, name: e.name, draft: isDraftNote(e.fm) };
237
217
 
238
218
  const shortcode = e.fm.shortcode;
239
219
  if (typeof shortcode === "string" && shortcode) {
@@ -263,36 +243,11 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
263
243
  index.set(`doc${type}/${shortcode}`.toLowerCase(), value);
264
244
  }
265
245
  }
266
-
267
- // The alias sources are the shared ones — `aliases`, `name.aliases`
268
- // and `name.full`, and deliberately **not** the filename (#131) — plus
269
- // this page's display name, which is what `name.full` becomes on the
270
- // site and is carried here already resolved.
271
- aliasEntries.push({
272
- type,
273
- aliases: [...aliasesOf(e.fm), e.name],
274
- value,
275
- });
276
246
  }
277
247
 
278
- // Built in one pass at the end, by the shared rule, so a collision is a
279
- // reportable fact rather than a silently deleted key. Two pages of one
280
- // type sharing an alias resolve to neither, and `aliasCollisions` names
281
- // every claimant — the citing page is innocent (#13, #131).
282
- const {
283
- byKey: typeAlias,
284
- collisions: aliasCollisions,
285
- claims: aliasClaims,
286
- } = indexAliases(aliasEntries, { same: (a, b) => a.url === b.url });
287
- const typeCollide = new Set(aliasCollisions.map((c) => c.key));
288
-
289
248
  return {
290
249
  index,
291
250
  ambiguous,
292
- typeAlias,
293
- typeCollide,
294
- aliasCollisions,
295
- aliasClaims,
296
251
  contentTypes,
297
252
  sections,
298
253
  packages,
@@ -313,32 +268,32 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
313
268
  * @param {object} options - Per-page inputs.
314
269
  * @param {string} options.src - Source path of the page being resolved, for
315
270
  * diagnostics.
316
- * @param {string|null} [options.type] - The citing note's type, which scopes a
317
- * bare alias lookup.
271
+ * @param {string|null} [options.type] - The citing note's type, carried for a
272
+ * consumer's own diagnostics.
318
273
  * @param {object[]} options.errors - Collector the resolver appends to.
274
+ * @param {string} [options.file] - The page's source file, which a link
275
+ * diagnostic names. Absent, `src` stands in.
319
276
  * @param {Map<string, object>} [options.foreignIndex] - The foreign index, for
320
277
  * resolvers that distinguish a foreign hit from a local one.
321
- * @param {boolean} [options.manifestsComplete] - Whether every package this
322
- * build links into supplied a manifest. When false, a resolver may soften an
323
- * unresolved cross-package link rather than fail.
324
278
  * @returns {object} The resolver context.
279
+ *
280
+ * There is deliberately **no `manifestsComplete`**. It used to let a resolver
281
+ * soften an unresolved cross-package address while any package's manifest was
282
+ * missing; #184 retired the softening, since the pack compilers and the link
283
+ * checker never had it and one authored link must not get two verdicts. A
284
+ * caller still passing it is ignored rather than obeyed.
325
285
  */
326
- export function wikiContext(
327
- built,
328
- { src, type = null, errors, foreignIndex = new Map(), manifestsComplete = true },
329
- ) {
286
+ export function wikiContext(built, { src, file, type = null, errors, foreignIndex = new Map() }) {
330
287
  return {
331
288
  index: built.index,
332
289
  foreign: foreignIndex,
333
- manifestsComplete,
334
290
  collide: built.ambiguous,
335
291
  sections: built.sections,
336
- typeAlias: built.typeAlias,
337
- typeCollide: built.typeCollide,
338
292
  contentTypes: built.contentTypes,
339
293
  packages: built.packages,
340
294
  type,
341
295
  errors,
342
296
  src,
297
+ file,
343
298
  };
344
299
  }