@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
@@ -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