@heroiclands/package-build 11.0.0 → 13.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.
- package/CHANGELOG.md +279 -0
- package/CONTENT.md +96 -49
- package/MIGRATING.md +65 -0
- package/content-config.mjs +51 -22
- package/docs/content-format.md +51 -27
- package/engine/base-compiler.mjs +6 -1
- package/engine/content-address.mjs +24 -73
- package/engine/frontmatter-lint.mjs +92 -1
- package/engine/manifest-emit.mjs +16 -18
- package/engine/note-vocabulary.mjs +159 -1
- package/engine/retired-fields.mjs +76 -3
- package/engine/site-build.mjs +70 -78
- package/engine/site-index.mjs +20 -8
- package/engine/web-wikilinks.mjs +1 -1
- package/engine/wikilinks.mjs +1 -1
- package/package.json +1 -1
- package/types/content-config.d.mts +29 -17
- package/types/engine/content-address.d.mts +22 -34
- package/types/engine/note-vocabulary.d.mts +76 -0
- package/types/engine/retired-fields.d.mts +47 -0
- package/types/engine/site-build.d.mts +56 -31
- package/types/engine/site-index.d.mts +9 -4
|
@@ -53,9 +53,31 @@
|
|
|
53
53
|
* one is already reported as a type no schema declares, which is the finding it
|
|
54
54
|
* deserves until the type exists.
|
|
55
55
|
*
|
|
56
|
+
* **A type name and a subType value are held to the address charset** (#206), so
|
|
57
|
+
* both are `^[A-Za-z0-9]+$` — the charset `engine/address-charset.mjs` states
|
|
58
|
+
* and the shortcode is already held to. For a type that is literal: it is the
|
|
59
|
+
* first segment of every address (`type-shortcode`), the hyphen is the
|
|
60
|
+
* separator between segments and can therefore never occur inside one, and a
|
|
61
|
+
* hyphenated name would be read back as two segments and resolve to nothing,
|
|
62
|
+
* reporting nothing about why.
|
|
63
|
+
*
|
|
64
|
+
* A subType reaches no address of its own. It did when this rule was written —
|
|
65
|
+
* a `doc`'s was its section, a path segment — and #204 retired sections from
|
|
66
|
+
* the note format one release later. It keeps the rule regardless, and the
|
|
67
|
+
* reason is not inertia: a subType is a vocabulary term the whole toolchain
|
|
68
|
+
* keys on, it is one closed set away from being an address again, and a charset
|
|
69
|
+
* that held for a type, a shortcode and a package but not for a subType would
|
|
70
|
+
* be a rule nobody could state in one sentence. The registry below is checked
|
|
71
|
+
* against it as this module loads, so a declaration that breaks it cannot be
|
|
72
|
+
* imported, let alone shipped.
|
|
73
|
+
*
|
|
56
74
|
* @module
|
|
57
75
|
*/
|
|
58
76
|
|
|
77
|
+
// The one charset, read rather than restated. A second spelling of the pattern
|
|
78
|
+
// is how the three disagreements found in #202/#203 happened.
|
|
79
|
+
import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./address-charset.mjs";
|
|
80
|
+
|
|
59
81
|
/**
|
|
60
82
|
* One `data:` key a note type may carry.
|
|
61
83
|
*
|
|
@@ -641,7 +663,12 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
641
663
|
/* ----- core documents ------------------------------------------- */
|
|
642
664
|
|
|
643
665
|
doc: Object.freeze({
|
|
644
|
-
|
|
666
|
+
// `userguide`, not `user-guide`: a `doc` routes by its subType, so the
|
|
667
|
+
// value is a path segment, and a segment carries no hyphen (#206). The
|
|
668
|
+
// old spelling is accepted transitionally — see {@link RETIRED_SUBTYPES}
|
|
669
|
+
// — but it is not declared here, because this list is what the format
|
|
670
|
+
// says a note *should* write.
|
|
671
|
+
subTypes: Object.freeze(["rules", "userguide", "reference"]),
|
|
645
672
|
data: Object.freeze([]),
|
|
646
673
|
}),
|
|
647
674
|
|
|
@@ -704,6 +731,137 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
704
731
|
}),
|
|
705
732
|
});
|
|
706
733
|
|
|
734
|
+
/**
|
|
735
|
+
* The retired spelling of a subType a type declares → what to write now (#206).
|
|
736
|
+
*
|
|
737
|
+
* Keyed by type, because a retirement is a statement about *that type's*
|
|
738
|
+
* vocabulary: `user-guide` on a `doc` is the old spelling of `userguide`, while
|
|
739
|
+
* the same string on any other type is nothing but a charset violation, and
|
|
740
|
+
* saying "did you mean userguide" there would be a guess dressed as a fact.
|
|
741
|
+
*
|
|
742
|
+
* **Recorded here rather than left in `subTypes`** so the declared list stays
|
|
743
|
+
* the list of values a note *should* write. A retired value is accepted, not
|
|
744
|
+
* declared — the difference is exactly what makes the finding possible.
|
|
745
|
+
*
|
|
746
|
+
* **Deliberately not the shape of a type rename** ({@link
|
|
747
|
+
* import("./ids.mjs").RETIRED_TYPES}), which is an error: a retired type routes
|
|
748
|
+
* a note to the wrong pack, whereas a retired subType still compiles to the
|
|
749
|
+
* correct page. The sweep is the consumer's, and the ordering is the reverse of
|
|
750
|
+
* the usual — the acceptance ships *first*, because declaring only the new
|
|
751
|
+
* spelling while 43 `sohl` notes still author the old one would invalidate all
|
|
752
|
+
* 43 with a release they had no chance to sweep ahead of. A later change
|
|
753
|
+
* removes this map, and the old spelling then falls through to the ordinary
|
|
754
|
+
* undeclared-value error with no code left to remove.
|
|
755
|
+
*
|
|
756
|
+
* @type {Readonly<Record<string, Readonly<Record<string, string>>>>}
|
|
757
|
+
*/
|
|
758
|
+
export const RETIRED_SUBTYPES = Object.freeze({
|
|
759
|
+
doc: Object.freeze({ "user-guide": "userguide" }),
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* What to write in place of a retired subType value, if it is one.
|
|
764
|
+
*
|
|
765
|
+
* @param {string} type - The note's `type`.
|
|
766
|
+
* @param {string} value - The authored `subType`.
|
|
767
|
+
* @param {Readonly<Record<string, Readonly<Record<string, string>>>>} [retired]
|
|
768
|
+
* The map to read, defaulting to {@link RETIRED_SUBTYPES}.
|
|
769
|
+
* @returns {string|undefined} The current spelling, or `undefined` when the
|
|
770
|
+
* value is not a retired one — which is not the same as it being valid.
|
|
771
|
+
*/
|
|
772
|
+
export function retiredSubType(type, value, retired = RETIRED_SUBTYPES) {
|
|
773
|
+
const forType = retired?.[type];
|
|
774
|
+
if (!forType || !Object.hasOwn(forType, value)) return undefined;
|
|
775
|
+
return forType[value];
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* What a note carrying a retired subType is told.
|
|
780
|
+
*
|
|
781
|
+
* One message, so the lint and any later refusal cannot describe the same
|
|
782
|
+
* retirement differently.
|
|
783
|
+
*
|
|
784
|
+
* @param {string} type - The note's `type`.
|
|
785
|
+
* @param {string} value - The retired spelling the note carries.
|
|
786
|
+
* @param {string} replacement - What to write instead.
|
|
787
|
+
* @returns {string} The message.
|
|
788
|
+
*/
|
|
789
|
+
export function retiredSubTypeMessage(type, value, replacement) {
|
|
790
|
+
return (
|
|
791
|
+
`\`subType\` "${value}" is a retired spelling of "${replacement}" on a ` +
|
|
792
|
+
`${type}; write "${replacement}". A subType is an address segment, and ` +
|
|
793
|
+
`a segment is ${ADDRESS_SEGMENT_PATTERN.source} — the hyphen separates ` +
|
|
794
|
+
`segments, so it can never occur inside one. The old spelling is still ` +
|
|
795
|
+
`accepted, and will stop being accepted once the trees have swept`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* What a note carrying a subType outside the address charset is told.
|
|
801
|
+
*
|
|
802
|
+
* @param {string} value - The authored `subType`.
|
|
803
|
+
* @returns {string} The message.
|
|
804
|
+
*/
|
|
805
|
+
export function subTypeCharsetMessage(value) {
|
|
806
|
+
return (
|
|
807
|
+
`\`subType\` "${value}" is not an address segment — a subType is ` +
|
|
808
|
+
`letters and digits only (${ADDRESS_SEGMENT_PATTERN.source}), the same ` +
|
|
809
|
+
`charset a shortcode is held to. The hyphen separates the segments of ` +
|
|
810
|
+
`an address, so a value containing one is read back as two segments ` +
|
|
811
|
+
`and resolves to nothing`
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* What a note carrying a type outside the address charset is told.
|
|
817
|
+
*
|
|
818
|
+
* @param {string} type - The authored `type`.
|
|
819
|
+
* @returns {string} The message.
|
|
820
|
+
*/
|
|
821
|
+
export function typeCharsetMessage(type) {
|
|
822
|
+
return (
|
|
823
|
+
`content type "${type}" is not an address segment — a type is letters ` +
|
|
824
|
+
`and digits only (${ADDRESS_SEGMENT_PATTERN.source}), the same charset ` +
|
|
825
|
+
`a shortcode is held to. A type is the first segment of every address ` +
|
|
826
|
+
`("type-shortcode"), so a hyphenated one is read back as two segments ` +
|
|
827
|
+
`and resolves to nothing`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Refuse a vocabulary that declares a type or subType outside the charset.
|
|
833
|
+
*
|
|
834
|
+
* Run over {@link NOTE_VOCABULARY} as this module loads, so a declaration that
|
|
835
|
+
* breaks the rule cannot be imported. That is stricter than a lint on purpose:
|
|
836
|
+
* a note's bad value is one author's mistake and belongs in a report, while a
|
|
837
|
+
* bad *declaration* would tell every author to write something unaddressable.
|
|
838
|
+
*
|
|
839
|
+
* @param {Readonly<Record<string, TypeVocabulary>>} vocabulary - The registry.
|
|
840
|
+
* @param {string} [where] - What declares it, for the message.
|
|
841
|
+
* @throws {Error} Naming every offending type and subType at once, rather than
|
|
842
|
+
* stopping at the first — a reader fixing a list wants the whole list.
|
|
843
|
+
*/
|
|
844
|
+
export function assertVocabularyCharset(vocabulary, where = "the note vocabulary") {
|
|
845
|
+
const bad = [];
|
|
846
|
+
for (const [type, entry] of Object.entries(vocabulary ?? {})) {
|
|
847
|
+
if (!isAddressSegment(type)) bad.push(`type "${type}"`);
|
|
848
|
+
const values = entry?.subTypes;
|
|
849
|
+
if (!Array.isArray(values)) continue;
|
|
850
|
+
for (const value of values) {
|
|
851
|
+
if (!isAddressSegment(value)) bad.push(`subType "${value}" on ${type}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (!bad.length) return;
|
|
855
|
+
throw new Error(
|
|
856
|
+
`${where} declares ${bad.join(", ")}, which ${bad.length === 1 ? "is" : "are"} ` +
|
|
857
|
+
`not ${ADDRESS_SEGMENT_PATTERN.source}. A type and a subType are both ` +
|
|
858
|
+
`address segments, and the hyphen separates segments rather than ` +
|
|
859
|
+
`occurring inside one.`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
assertVocabularyCharset(NOTE_VOCABULARY);
|
|
864
|
+
|
|
707
865
|
/**
|
|
708
866
|
* The `data:` keys a note type may carry.
|
|
709
867
|
*
|
|
@@ -22,9 +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
|
|
26
|
-
* `aliases:` have no such home — there is no surviving concept
|
|
27
|
-
* of — so they are refused here.
|
|
25
|
+
* where the concept it belonged to still lives. `draft:`, the top-level
|
|
26
|
+
* `aliases:` and `section:` have no such home — there is no surviving concept
|
|
27
|
+
* any of them was part of — so they are refused here.
|
|
28
28
|
*
|
|
29
29
|
* **What `draft:` did (#69).** It excluded a note from the compiled packs, from
|
|
30
30
|
* the link manifest and from a consuming site build. Nothing reported the
|
|
@@ -41,6 +41,13 @@
|
|
|
41
41
|
* `name.full` and so decided what a note could be named (#179). The form and
|
|
42
42
|
* the index are retired together, leaving the field with no reader at all.
|
|
43
43
|
*
|
|
44
|
+
* **What `section:` did (#202).** It named the section a `collection` note
|
|
45
|
+
* headed, under the `collection` landing rule — its only reader anywhere. That
|
|
46
|
+
* rule is retired, a section being landed by the `README.md` in its directory,
|
|
47
|
+
* so the field has none. No schema or vocabulary ever declared it either, and
|
|
48
|
+
* nothing checks unrecognized top-level keys, so left in place it would be
|
|
49
|
+
* silently ignored rather than reported.
|
|
50
|
+
*
|
|
44
51
|
* **`name.aliases` fed the same index and is nonetheless kept.** It is
|
|
45
52
|
* **reserved** — held for a use that does not exist yet — so it is the one
|
|
46
53
|
* field here that is neither retired nor read. Nothing consults it: no index,
|
|
@@ -216,6 +223,72 @@ export function declaresRetiredAliasesField(fm) {
|
|
|
216
223
|
return Boolean(fm) && typeof fm === "object" && Object.hasOwn(fm, "aliases");
|
|
217
224
|
}
|
|
218
225
|
|
|
226
|
+
/**
|
|
227
|
+
* What a note declaring `section:` is told, in one place.
|
|
228
|
+
*
|
|
229
|
+
* Shared by the compile-time refusal and the frontmatter lint, because an
|
|
230
|
+
* author meets whichever of the two runs first and they should read the same.
|
|
231
|
+
* It names what lands a section now rather than a value to correct: no value
|
|
232
|
+
* makes declaring the field right.
|
|
233
|
+
*
|
|
234
|
+
* **What it did (#202).** It named the section a `collection` note headed,
|
|
235
|
+
* under the `collection` landing rule — the only reader it ever had, in the
|
|
236
|
+
* second branch of `landingOf` (`engine/content-address.mjs`). That rule went
|
|
237
|
+
* first, and the whole mechanism went with it (#204): a section is a Hugo
|
|
238
|
+
* directory the note format does not carry, so no note lands one and a page
|
|
239
|
+
* that introduces a type is an ordinary note addressed `doc-<type>`. Nothing
|
|
240
|
+
* else read the field, and no schema or vocabulary declared it, so left in
|
|
241
|
+
* place it would be ignored in silence — the note saying one thing and the
|
|
242
|
+
* build doing another.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} [file] - The note's path, named in the message. Omit it where
|
|
245
|
+
* the caller emits through a diagnostic, whose locator already starts the
|
|
246
|
+
* line — repeating it prints the path twice.
|
|
247
|
+
* @returns {string} The message, unpunctuated at the end as a finding is.
|
|
248
|
+
*/
|
|
249
|
+
export function sectionRetiredMessage(file) {
|
|
250
|
+
return (
|
|
251
|
+
"`section:` is a retired frontmatter field — delete it" +
|
|
252
|
+
(file ? ` — ${file}` : "") +
|
|
253
|
+
". It named the section a `collection` note headed, and both the rule " +
|
|
254
|
+
"and the sections it routed to are retired: a page that introduces " +
|
|
255
|
+
"the notes of a type is an ordinary note — `type: doc`, " +
|
|
256
|
+
"`subType: reference`, `shortcode: <type>` — addressed `doc-<type>`. " +
|
|
257
|
+
"Nothing else ever read the field"
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Refuse a note that declares `section:` at all.
|
|
263
|
+
*
|
|
264
|
+
* Presence is the whole test, as it is for `draft:` and `aliases:`: an empty
|
|
265
|
+
* value reads as "this note heads a section and names none", a statement about
|
|
266
|
+
* a rule that no longer exists.
|
|
267
|
+
*
|
|
268
|
+
* @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
|
|
269
|
+
* could not be parsed.
|
|
270
|
+
* @param {object} [options] - Options.
|
|
271
|
+
* @param {string} [options.file] - The note's path, named in the message. Omit
|
|
272
|
+
* it where the caller emits through a diagnostic, which puts the locator at
|
|
273
|
+
* the start of the line already — repeating it prints the path twice.
|
|
274
|
+
* @param {string} [options.absPath] - The note's file on disk, read only on the
|
|
275
|
+
* failing path to locate the offending line and column. The position rides on
|
|
276
|
+
* the thrown error as `position`, for a caller that emits a diagnostic.
|
|
277
|
+
* @returns {void}
|
|
278
|
+
* @throws {Error} When the note declares the field.
|
|
279
|
+
*/
|
|
280
|
+
export function assertNoSectionField(fm, { file, absPath } = {}) {
|
|
281
|
+
if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "section")) return;
|
|
282
|
+
|
|
283
|
+
const err = new Error(`${sectionRetiredMessage(file)}.`);
|
|
284
|
+
// Anchored at column 1: `site.trees[].section` is a *configuration* key of
|
|
285
|
+
// the same name, and a nested `section:` inside some other block is not
|
|
286
|
+
// this field — a finding about the top-level one must not open on it.
|
|
287
|
+
const position = locateFrontmatterKey(absPath, "section", undefined, { topLevel: true });
|
|
288
|
+
if (position) err.position = position;
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
|
|
219
292
|
/**
|
|
220
293
|
* A frontmatter key's position in a note's file, or nothing.
|
|
221
294
|
*
|
package/engine/site-build.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { createRequire } from "node:module";
|
|
|
50
50
|
import matter from "gray-matter";
|
|
51
51
|
|
|
52
52
|
import { slugify } from "./content-slug.mjs";
|
|
53
|
-
import { addressSlug
|
|
53
|
+
import { addressSlug } from "./content-address.mjs";
|
|
54
54
|
import { protectCode } from "./code-fences.mjs";
|
|
55
55
|
import { expandContentTables } from "./content-tables.mjs";
|
|
56
56
|
import { buildSiteIndex, wikiContext } from "./site-index.mjs";
|
|
@@ -132,11 +132,10 @@ function readNote(file) {
|
|
|
132
132
|
* @returns {{pages: object[], addressFindings: object[], fmLinkFindings: object[]}}
|
|
133
133
|
*/
|
|
134
134
|
export function collectContentPages(contentBase, ctx) {
|
|
135
|
-
// Where an addressed page publishes.
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
// being collected as a finding (#195).
|
|
135
|
+
// Where an addressed page publishes. A missing `base` would put *every*
|
|
136
|
+
// page at `undefined/` rather than one, and it is the caller's contract
|
|
137
|
+
// rather than a note's defect, so it throws instead of being collected as a
|
|
138
|
+
// finding (#195).
|
|
140
139
|
if (typeof ctx.base !== "string" || !ctx.base) {
|
|
141
140
|
throw new TypeError(
|
|
142
141
|
"collectContentPages: `ctx.base` must be a non-empty string — it is the package address every page's URL is built on",
|
|
@@ -178,20 +177,6 @@ export function collectContentPages(contentBase, ctx) {
|
|
|
178
177
|
}
|
|
179
178
|
|
|
180
179
|
const base = path.basename(file);
|
|
181
|
-
const isReadme = base.toLowerCase() === "readme.md";
|
|
182
|
-
const sec = sectionOf(fm);
|
|
183
|
-
// A page's URL no longer contains its section, but the section is still
|
|
184
|
-
// what decides the directory the file is written to — and Hugo derives
|
|
185
|
-
// a page's section from that directory, not from its URL. So a note
|
|
186
|
-
// with none is still a note with nowhere to be published, and is
|
|
187
|
-
// reported rather than written to `undefined/`.
|
|
188
|
-
if (typeof sec !== "string" || !sec) {
|
|
189
|
-
addressFindings.push({
|
|
190
|
-
file,
|
|
191
|
-
reason: `type "${fm.type}" has no section, so there is nowhere to file the page`,
|
|
192
|
-
});
|
|
193
|
-
continue;
|
|
194
|
-
}
|
|
195
180
|
const rel = path.relative(contentBase, file);
|
|
196
181
|
pages.push({
|
|
197
182
|
kind: "content",
|
|
@@ -218,15 +203,12 @@ export function collectContentPages(contentBase, ctx) {
|
|
|
218
203
|
// The immediate source subfolder, the only surviving record of the
|
|
219
204
|
// authoring folder, for grouped landings.
|
|
220
205
|
folder: path.basename(path.dirname(file)),
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
// so Hugo publishes it at its address rather than at its path.
|
|
228
|
-
url: isReadme ? `${ctx.mount}${sec}/` : `${ctx.base}${slug}/`,
|
|
229
|
-
isReadme,
|
|
206
|
+
// Every page is addressed by `(type, shortcode)` at the package
|
|
207
|
+
// root, which takes no content mount (#181). The file is written
|
|
208
|
+
// flat under the mount — see {@link pageDestination} — and the
|
|
209
|
+
// front matter carries this `url` so Hugo publishes it at its
|
|
210
|
+
// address rather than at its path.
|
|
211
|
+
url: `${ctx.base}${slug}/`,
|
|
230
212
|
});
|
|
231
213
|
}
|
|
232
214
|
return { pages, addressFindings, fmLinkFindings };
|
|
@@ -557,12 +539,10 @@ export function sectionFrontmatter(meta) {
|
|
|
557
539
|
* redirects of its own.
|
|
558
540
|
*
|
|
559
541
|
* A content page states its own **`url`**, which is its address rather than its
|
|
560
|
-
* path (#181).
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
* lookup. So the directory stays and the address is stated, and the two are
|
|
565
|
-
* free to differ.
|
|
542
|
+
* path (#181). It is written flat under the content mount (#204), so Hugo would
|
|
543
|
+
* otherwise publish it at `<mount><type>-<shortcode>/` rather than at the
|
|
544
|
+
* package-wide address the link manifest records — the same address, one
|
|
545
|
+
* segment too deep. So the address is stated and the mount does not reach it.
|
|
566
546
|
*
|
|
567
547
|
* A content page carries the package the build **derived** (#65). No note
|
|
568
548
|
* declares one — `package:` is retired (#56) — so the note's frontmatter alone
|
|
@@ -574,8 +554,12 @@ export function sectionFrontmatter(meta) {
|
|
|
574
554
|
* self-describing and makes sweeping the field out of a content tree
|
|
575
555
|
* output-preserving for a site as it already is for the packs.
|
|
576
556
|
*
|
|
557
|
+
* A **tree** page is the one that still reads `readmeSections`: a `trees` entry
|
|
558
|
+
* keeps its source layout below a named section, so its own `README` is that
|
|
559
|
+
* section's landing and takes the title and hero the section declares.
|
|
560
|
+
*
|
|
577
561
|
* @param {object} page - The page.
|
|
578
|
-
* @param {object} options - `{
|
|
562
|
+
* @param {object} options - `{ readmeSections, decorate }`.
|
|
579
563
|
* @returns {object} The frontmatter to write.
|
|
580
564
|
*/
|
|
581
565
|
export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
@@ -598,14 +582,6 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
|
598
582
|
kbfolder: page.folder,
|
|
599
583
|
};
|
|
600
584
|
if (decorate) decorate(data, page);
|
|
601
|
-
if (isReadme) {
|
|
602
|
-
const meta = readmeSections[sec];
|
|
603
|
-
// What the section says about itself wins over what its README
|
|
604
|
-
// happens to carry — the landing has to match the card linking to
|
|
605
|
-
// it. Assigned rather than transcribed key by key, so a section's
|
|
606
|
-
// vocabulary is decided in one place (#91).
|
|
607
|
-
if (meta) Object.assign(data, sectionFrontmatter(meta));
|
|
608
|
-
}
|
|
609
585
|
} else {
|
|
610
586
|
// A tree's own landing describes the *mount*, and nothing beneath it. A
|
|
611
587
|
// nested README is a sub-section's landing, and reading the section's
|
|
@@ -623,23 +599,25 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
|
623
599
|
/**
|
|
624
600
|
* Where a page is written, relative to the output root.
|
|
625
601
|
*
|
|
626
|
-
* **
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
* Hugo
|
|
630
|
-
*
|
|
631
|
-
*
|
|
602
|
+
* **Flat, under the mount, named by its address** (#204). A content page's URL
|
|
603
|
+
* is its address — `/<package>/<type>-<shortcode>/` — and the file is now named
|
|
604
|
+
* the same way, so the two agree. It used to be filed into `<section>/` so that
|
|
605
|
+
* Hugo would read a section off its path; a section appears in no address, and
|
|
606
|
+
* a directory chosen only to satisfy a rendering engine's idea of what a
|
|
607
|
+
* section is has no business in the note format.
|
|
608
|
+
*
|
|
609
|
+
* The name is the *whole* address rather than a section-relative half of it, so
|
|
610
|
+
* two types cannot fight over one file: a `doc` note's `subType` may be spelled
|
|
611
|
+
* the same as another note's `type`, and `doc-gear.md` and `weapongear-gear.md`
|
|
612
|
+
* are distinct whatever the sections used to be.
|
|
632
613
|
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
614
|
+
* **A `trees` entry is the exception, and always was.** Those pages preserve
|
|
615
|
+
* their source layout below a named section — they are a book with chapters,
|
|
616
|
+
* addressed by their path — so a `README` there is still its directory's
|
|
617
|
+
* `_index.md`.
|
|
636
618
|
*/
|
|
637
619
|
export function pageDestination(page) {
|
|
638
|
-
if (page.kind === "content") {
|
|
639
|
-
return page.isReadme ?
|
|
640
|
-
path.join(page.sec, "_index.md")
|
|
641
|
-
: path.join(page.sec, `${page.slug}.md`);
|
|
642
|
-
}
|
|
620
|
+
if (page.kind === "content") return `${page.slug}.md`;
|
|
643
621
|
const rel =
|
|
644
622
|
page.isReadme ? path.posix.join(path.posix.dirname(page.rel), "_index.md") : page.rel;
|
|
645
623
|
return path.join(page.sec, rel);
|
|
@@ -681,7 +659,11 @@ export function renderPages(pages, options) {
|
|
|
681
659
|
const byKind = {};
|
|
682
660
|
|
|
683
661
|
for (const page of pages) {
|
|
684
|
-
|
|
662
|
+
// The page's path in the tree an author edits: below the content root
|
|
663
|
+
// for a content note, below the tree's own root for a `trees` page. It
|
|
664
|
+
// used to be composed as `<section>/<basename>` for a content note,
|
|
665
|
+
// which named a directory that was never the note's (#204).
|
|
666
|
+
const src = page.relPath ?? page.rel ?? page.base;
|
|
685
667
|
const ctx = wikiContext(index, {
|
|
686
668
|
src,
|
|
687
669
|
file: page.file,
|
|
@@ -724,26 +706,43 @@ export function renderPages(pages, options) {
|
|
|
724
706
|
}
|
|
725
707
|
|
|
726
708
|
/**
|
|
727
|
-
* Writes the
|
|
709
|
+
* Writes the Hugo sections a published tree declares.
|
|
710
|
+
*
|
|
711
|
+
* **This is where a section lives now, and the only place** (#204). A content
|
|
712
|
+
* note carries none: it is addressed by `(type, shortcode)` and emitted flat
|
|
713
|
+
* under the mount, so nothing a page does creates a directory. A site that wants
|
|
714
|
+
* `/<package>/<prefix><section>/` to answer — with a title, a hero, and whatever
|
|
715
|
+
* listing its layout builds — says so here, in configuration, and this writes
|
|
716
|
+
* the `_index.md` that makes Hugo agree it is a section.
|
|
728
717
|
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
718
|
+
* Three jobs, all of them Hugo's directory semantics rather than the note
|
|
719
|
+
* format's:
|
|
731
720
|
*
|
|
721
|
+
* - **The mount's own landing**, so `/<package>/<prefix>` is a page rather than
|
|
722
|
+
* a directory listing. It carries a `type` of its own: Hugo's template lookup
|
|
723
|
+
* walks up a page's path, so an untyped landing template at the mount would
|
|
724
|
+
* also serve every section below it that has none.
|
|
732
725
|
* - **Declared sections** get a titled `_index.md` with their hero, so a landing
|
|
733
726
|
* matches the card that links to it instead of showing Hugo's auto-humanised
|
|
734
|
-
* directory name. The body is empty, which lets the theme
|
|
735
|
-
*
|
|
736
|
-
* - **Every other section directly under the mount** gets a bare `_index.md`,
|
|
727
|
+
* directory name. The body is empty, which lets the theme decide what to list.
|
|
728
|
+
* - **Every other directory directly under the mount** gets a bare `_index.md`,
|
|
737
729
|
* or its own address publishes nothing. Hugo generates a section page
|
|
738
730
|
* automatically only for a *top-level* content directory; below that, a
|
|
739
|
-
* directory without an `_index.md` is not a section, so its URL 404s while
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
731
|
+
* directory without an `_index.md` is not a section, so its URL 404s while its
|
|
732
|
+
* children publish normally. With content pages flat, what that reaches is a
|
|
733
|
+
* `trees` entry's directory — the one thing left below the mount that a note
|
|
734
|
+
* creates.
|
|
735
|
+
*
|
|
736
|
+
* **A section listing is not a page listing any more.** A layout that reads
|
|
737
|
+
* `.Pages` off a section it declares here will find nothing, because no file is
|
|
738
|
+
* filed into it; one that queries `site.RegularPages` by `Params.type` — which
|
|
739
|
+
* is how `sohl`'s eleven catalog layouts already work — is unaffected. That is a
|
|
740
|
+
* consumer's layout to choose, and it is stated here because the choice is no
|
|
741
|
+
* longer free.
|
|
743
742
|
*
|
|
744
743
|
* Scoped to one level on purpose. A directory further down was not a section
|
|
745
|
-
* before
|
|
746
|
-
*
|
|
744
|
+
* before either, and giving it one here would silently re-scope the prev/next
|
|
745
|
+
* navigation of every page inside it.
|
|
747
746
|
*
|
|
748
747
|
* @param {string} outRoot - The mount directory.
|
|
749
748
|
* @param {object} options - `{ sections, landing, sectionTitle }`.
|
|
@@ -752,11 +751,6 @@ export function renderPages(pages, options) {
|
|
|
752
751
|
export function writeSectionLandings(outRoot, { sections = {}, landing, sectionTitle }) {
|
|
753
752
|
let written = 0;
|
|
754
753
|
|
|
755
|
-
// The mount's own landing carries a `type` of its own. Hugo's template
|
|
756
|
-
// lookup walks up a page's path, so a landing template at the mount would
|
|
757
|
-
// also serve every section below it that has no template of its own —
|
|
758
|
-
// each would render the mount's front page. Typing the landing moves its
|
|
759
|
-
// template out of the path where it could be inherited.
|
|
760
754
|
if (landing) {
|
|
761
755
|
fs.mkdirSync(outRoot, { recursive: true });
|
|
762
756
|
fs.writeFileSync(path.join(outRoot, "_index.md"), matter.stringify("", landing));
|
|
@@ -1024,9 +1018,7 @@ export function buildSite({ config, outRoot } = {}) {
|
|
|
1024
1018
|
name: page.fm.name?.full ?? homepageTitle(page.fm, resolved),
|
|
1025
1019
|
slug: addressSlug(page.fm),
|
|
1026
1020
|
base: path.basename(page.file),
|
|
1027
|
-
sec: sectionOf(page.fm),
|
|
1028
1021
|
url: `${base}${addressSlug(page.fm)}/`,
|
|
1029
|
-
isReadme: false,
|
|
1030
1022
|
}));
|
|
1031
1023
|
|
|
1032
1024
|
const trees = site.trees.map((t) => ({
|
package/engine/site-index.mjs
CHANGED
|
@@ -65,10 +65,15 @@ import { isDraftNote } from "./note-vocabulary.mjs";
|
|
|
65
65
|
* @property {object} fm The note's frontmatter.
|
|
66
66
|
* @property {string} name Display name.
|
|
67
67
|
* @property {string} slug URL segment.
|
|
68
|
-
* @property {string} sec
|
|
68
|
+
* @property {string} [sec] The Hugo section a **tree** page is filed under, and
|
|
69
|
+
* the first segment of the `<sec>/<slug>` address it
|
|
70
|
+
* is reachable by. A content page has none: it is
|
|
71
|
+
* addressed by `(type, shortcode)` and emitted flat
|
|
72
|
+
* (#204).
|
|
69
73
|
* @property {string} base Source file's basename, e.g. `Climbing.md`.
|
|
70
74
|
* @property {string} url The page's published address.
|
|
71
|
-
* @property {boolean} isReadme Whether
|
|
75
|
+
* @property {boolean} [isReadme] Whether a tree page is its directory's
|
|
76
|
+
* landing.
|
|
72
77
|
*/
|
|
73
78
|
|
|
74
79
|
/**
|
|
@@ -169,13 +174,20 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
|
|
|
169
174
|
const ownPackage = contentPackage();
|
|
170
175
|
const packages = new Set(ownPackage ? [ownPackage] : []);
|
|
171
176
|
|
|
172
|
-
// `section/slug` is unique by construction
|
|
173
|
-
//
|
|
174
|
-
// `
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
+
// `section/slug` is unique by construction, and is now a **tree** page's
|
|
178
|
+
// address: a `trees` entry keeps its source layout below a named section,
|
|
179
|
+
// so `dev-docs/testing` is how one is cited. A content page carries no
|
|
180
|
+
// section at all (#204) and is addressed by `(type, shortcode)` below —
|
|
181
|
+
// indexing it here as well would have written `weapongear/weapongear-dagger`,
|
|
182
|
+
// a key no author could reasonably write.
|
|
183
|
+
//
|
|
184
|
+
// A page's name, filename and bare slug were indexed here too, as
|
|
185
|
+
// collision-aware fallbacks the bare `[[Name]]` form looked up; that form is
|
|
186
|
+
// retired and nothing consults them, so they are gone and with them the rule
|
|
187
|
+
// that two pages of a type may not share a name (#179, #180).
|
|
177
188
|
for (const e of entries) {
|
|
178
|
-
|
|
189
|
+
if (typeof e.sec !== "string" || !e.sec) continue;
|
|
190
|
+
sections.add(e.sec.toLowerCase());
|
|
179
191
|
// `draft` rides on every key a page is addressable by, because a link
|
|
180
192
|
// into a draft note renders marked whichever of them the author wrote
|
|
181
193
|
// (#183). It decides nothing about resolution: the page is indexed and
|
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* pack build together.
|
|
29
29
|
*
|
|
30
30
|
* The KB *section* is not always the type: prose pages (`type: doc`) route by
|
|
31
|
-
* their `category`, so `doc/quickstart` lands on `/
|
|
31
|
+
* their `category`, so `doc/quickstart` lands on `/userguide/sohl-quickstart/`.
|
|
32
32
|
* The caller supplies that mapping already resolved, in the index it builds.
|
|
33
33
|
*
|
|
34
34
|
* Lives here rather than in a consumer so every package resolves a link the
|
package/engine/wikilinks.mjs
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* Nothing narrower than `(type, shortcode)` is consulted — a note's directory
|
|
37
37
|
* and its `category` play no part in resolution — and nothing wider: a note's
|
|
38
38
|
* *name* is not an address, so two notes of a type may share a display name
|
|
39
|
-
* ("Gear" as a rules page and as a user
|
|
39
|
+
* ("Gear" as a rules page and as a user guide page) with nothing to disambiguate
|
|
40
40
|
* (#179, #180).
|
|
41
41
|
*
|
|
42
42
|
* At compile time each becomes a Foundry UUID enricher, routed to the pack that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "13.0.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|