@heroiclands/package-build 6.0.0 → 7.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 +798 -0
- package/CONTENT.md +228 -4
- package/bin/content-build.mjs +196 -10
- package/bin/package-build.mjs +8 -1
- package/config.mjs +25 -3
- package/content-config.mjs +283 -29
- package/engine/address-diff.mjs +290 -0
- package/engine/base-compiler.mjs +25 -0
- package/engine/content-links.mjs +132 -27
- package/engine/content-lint.mjs +23 -2
- package/engine/diagnostics.mjs +61 -1
- package/engine/frontmatter-lint.mjs +22 -0
- package/engine/generate.mjs +10 -5
- package/engine/helpers.mjs +38 -0
- package/engine/homepage.mjs +206 -2
- package/engine/journals.mjs +8 -1
- package/engine/macros.mjs +2 -0
- package/engine/pack-config.mjs +143 -13
- package/engine/prose-lint.mjs +10 -2
- package/engine/scenes.mjs +2 -2
- package/engine/site-build.mjs +74 -19
- package/engine/web-wikilinks.mjs +13 -4
- package/engine/wikilink-syntax.mjs +25 -0
- package/engine/wikilinks.mjs +6 -3
- package/manifest.mjs +37 -2
- package/package.json +5 -3
- package/sohl/actors.mjs +23 -10
- package/sohl/items.mjs +1 -1
- package/types/content-config.d.mts +14 -0
- package/types/engine/address-diff.d.mts +108 -0
- package/types/engine/base-compiler.d.mts +18 -1
- package/types/engine/content-links.d.mts +11 -3
- package/types/engine/content-lint.d.mts +4 -1
- package/types/engine/diagnostics.d.mts +33 -1
- package/types/engine/generate.d.mts +3 -2
- package/types/engine/helpers.d.mts +29 -3
- package/types/engine/homepage.d.mts +117 -0
- package/types/engine/journals.d.mts +7 -1
- package/types/engine/pack-config.d.mts +22 -0
- package/types/engine/prose-lint.d.mts +10 -2
- package/types/engine/site-build.d.mts +28 -3
- package/types/engine/wikilink-syntax.d.mts +24 -0
- package/types/sohl/actors.d.mts +3 -3
package/engine/diagnostics.mjs
CHANGED
|
@@ -289,12 +289,25 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
|
|
|
289
289
|
* resolves to nothing — yields `{}`, so a caller spreads the result and the
|
|
290
290
|
* position is dropped rather than guessed.
|
|
291
291
|
*
|
|
292
|
+
* **`key: true` addresses the declaration rather than the value.** A finding
|
|
293
|
+
* about a *value* — this pack name is not in `packs[]` — belongs on the value,
|
|
294
|
+
* which is the default. A finding that names a **field** — `\`site.sections.x\`
|
|
295
|
+
* is not a recognized option` — sends the reader to look for that field, so the
|
|
296
|
+
* position should be the field's own, and in a flow mapping
|
|
297
|
+
* (`{ title: X, banner: Y }`) the two are different columns on one line. The
|
|
298
|
+
* key's node is found through the same parse, so this stays one locator rather
|
|
299
|
+
* than a second one free to disagree with it.
|
|
300
|
+
*
|
|
292
301
|
* @param {string} text - The document's contents.
|
|
293
302
|
* @param {ReadonlyArray<string|number>} keyPath - Path to the node: map keys as
|
|
294
303
|
* strings, sequence entries as numbers.
|
|
304
|
+
* @param {object} [opts]
|
|
305
|
+
* @param {boolean} [opts.key=false] - Report where the last segment is
|
|
306
|
+
* *declared* rather than where its value sits. Ignored for a sequence entry,
|
|
307
|
+
* which has no key.
|
|
295
308
|
* @returns {{line?: number, column?: number}} Spreadable position fields.
|
|
296
309
|
*/
|
|
297
|
-
export function positionOfYamlPath(text, keyPath) {
|
|
310
|
+
export function positionOfYamlPath(text, keyPath, { key = false } = {}) {
|
|
298
311
|
if (typeof text !== "string" || !text) return {};
|
|
299
312
|
if (!Array.isArray(keyPath) || keyPath.length === 0) return {};
|
|
300
313
|
|
|
@@ -305,6 +318,19 @@ export function positionOfYamlPath(text, keyPath) {
|
|
|
305
318
|
// `keepScalar` returns the Scalar node rather than its value, which is
|
|
306
319
|
// the only form carrying a range.
|
|
307
320
|
node = doc.getIn(keyPath, true);
|
|
321
|
+
if (key && node !== undefined) {
|
|
322
|
+
const last = keyPath[keyPath.length - 1];
|
|
323
|
+
const parent =
|
|
324
|
+
keyPath.length === 1 ?
|
|
325
|
+
doc.contents
|
|
326
|
+
: doc.getIn(keyPath.slice(0, -1), true);
|
|
327
|
+
const pair = parent?.items?.find?.(
|
|
328
|
+
(item) =>
|
|
329
|
+
item?.key != null &&
|
|
330
|
+
String(item.key.value) === String(last),
|
|
331
|
+
);
|
|
332
|
+
if (pair?.key?.range) node = pair.key;
|
|
333
|
+
}
|
|
308
334
|
} catch {
|
|
309
335
|
return {};
|
|
310
336
|
}
|
|
@@ -314,3 +340,37 @@ export function positionOfYamlPath(text, keyPath) {
|
|
|
314
340
|
const { line, col } = counter.linePos(start);
|
|
315
341
|
return { line, column: col };
|
|
316
342
|
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The YAML key path a **dotted field path** addresses.
|
|
346
|
+
*
|
|
347
|
+
* Configuration checks report the offending key as the path a reader would
|
|
348
|
+
* write it — `packs[1].name`, `site.sections.affliction.title` — because that
|
|
349
|
+
* is what the message has to say. {@link positionOfYamlPath} addresses a node
|
|
350
|
+
* by segments instead, so this is the one translation between them: `.`
|
|
351
|
+
* separates map keys, and a bracketed suffix is a sequence index.
|
|
352
|
+
*
|
|
353
|
+
* A path this cannot parse yields `[]`, which {@link positionOfYamlPath} in
|
|
354
|
+
* turn resolves to no position — dropped rather than guessed, as everything
|
|
355
|
+
* else here is.
|
|
356
|
+
*
|
|
357
|
+
* @param {string} field - The dotted path, as a diagnostic spells it.
|
|
358
|
+
* @returns {Array<string|number>} Its segments, sequence indices as numbers.
|
|
359
|
+
*/
|
|
360
|
+
export function yamlKeyPath(field) {
|
|
361
|
+
if (typeof field !== "string" || field === "") return [];
|
|
362
|
+
/** @type {Array<string|number>} */
|
|
363
|
+
const segments = [];
|
|
364
|
+
for (const segment of field.split(".")) {
|
|
365
|
+
const parsed = /^([^[\]]*)((?:\[\d+\])*)$/.exec(segment);
|
|
366
|
+
// Anything else is not a path this understands — a key holding a `[`,
|
|
367
|
+
// or a dot inside a key. Refuse the whole path rather than resolve
|
|
368
|
+
// part of it to a node that is not the one named.
|
|
369
|
+
if (!parsed) return [];
|
|
370
|
+
if (parsed[1] !== "") segments.push(parsed[1]);
|
|
371
|
+
for (const index of parsed[2].matchAll(/\[(\d+)\]/g)) {
|
|
372
|
+
segments.push(Number(index[1]));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return segments;
|
|
376
|
+
}
|
|
@@ -40,6 +40,13 @@
|
|
|
40
40
|
* vocabulary (an adventure module ships skills, beings and magic swords), so
|
|
41
41
|
* every consumer loads all of it.
|
|
42
42
|
*
|
|
43
|
+
* The two rules that are not schema-driven sit on the *note format* side of
|
|
44
|
+
* that line, which is why they are here and not in `sohl/`: the retired
|
|
45
|
+
* top-level fields, and the address-bearing fields a `type: homepage` note
|
|
46
|
+
* refuses (#53). Each supplies its own message from the module that owns the
|
|
47
|
+
* knowledge — `retired-fields.mjs` and `homepage.mjs` — and this module only
|
|
48
|
+
* locates it in the file.
|
|
49
|
+
*
|
|
43
50
|
* **It takes a built link index rather than walking itself.** The dead-
|
|
44
51
|
* reference check has to resolve exactly as a wikilink does, cross-package
|
|
45
52
|
* manifests and all, and the way to guarantee that is to call the same
|
|
@@ -50,6 +57,7 @@
|
|
|
50
57
|
|
|
51
58
|
import { authoredFields } from "./field-spec.mjs";
|
|
52
59
|
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
60
|
+
import { checkHomepageAddressFields } from "./homepage.mjs";
|
|
53
61
|
import { RETIRED_TYPES } from "./ids.mjs";
|
|
54
62
|
import { draftRetiredMessage } from "./retired-fields.mjs";
|
|
55
63
|
|
|
@@ -237,6 +245,20 @@ export function lintNote(note, { schemas, index }) {
|
|
|
237
245
|
});
|
|
238
246
|
}
|
|
239
247
|
|
|
248
|
+
// A homepage's address is its package's, so the top-level fields that
|
|
249
|
+
// decide an address decide nothing on it (#53). Reported beside the retired
|
|
250
|
+
// fields above because it is the same kind of statement — a top-level key
|
|
251
|
+
// this note may not write — and, like them, it must survive the two early
|
|
252
|
+
// returns below: the finding stands whatever else the type is.
|
|
253
|
+
for (const { key, message } of checkHomepageAddressFields(fm)) {
|
|
254
|
+
findings.push({
|
|
255
|
+
file: note.file,
|
|
256
|
+
...at(key),
|
|
257
|
+
severity: "error",
|
|
258
|
+
message,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
240
262
|
const replacement = RETIRED_TYPES[type];
|
|
241
263
|
if (replacement) {
|
|
242
264
|
findings.push({
|
package/engine/generate.mjs
CHANGED
|
@@ -49,7 +49,7 @@ import { Actors } from "../sohl/actors.mjs";
|
|
|
49
49
|
import { Macros } from "./macros.mjs";
|
|
50
50
|
import { Scenes } from "./scenes.mjs";
|
|
51
51
|
import {
|
|
52
|
-
|
|
52
|
+
statsForPack,
|
|
53
53
|
loadFolders,
|
|
54
54
|
buildFolderResolver,
|
|
55
55
|
writeFolderDocs,
|
|
@@ -102,8 +102,9 @@ export const packJsonDir = (name, config = loadPackConfig()) =>
|
|
|
102
102
|
* @param {object} [config] - The resolved build configuration. Defaults to this
|
|
103
103
|
* repository's.
|
|
104
104
|
* @returns {string[]} Each Item pack's JSON directory. Empty when the
|
|
105
|
-
* repository ships no items at all
|
|
106
|
-
*
|
|
105
|
+
* repository ships no items at all, which is a legitimate package: the actors
|
|
106
|
+
* pass accepts an empty list and reports an item it cannot resolve per
|
|
107
|
+
* `(type, shortcode)` instead, naming the being (#49).
|
|
107
108
|
*/
|
|
108
109
|
export function itemPackJsonDirs(config = loadPackConfig()) {
|
|
109
110
|
return config.packs
|
|
@@ -246,7 +247,7 @@ export function unsatisfiedPassDependencies(running, config) {
|
|
|
246
247
|
* count (0 on success) and the number of entries it wrote.
|
|
247
248
|
*/
|
|
248
249
|
async function generatePack(
|
|
249
|
-
{ name, type, folders, companions },
|
|
250
|
+
{ name, type, folders, companions, system },
|
|
250
251
|
config,
|
|
251
252
|
router,
|
|
252
253
|
routingReporter,
|
|
@@ -290,7 +291,9 @@ async function generatePack(
|
|
|
290
291
|
companionDests[companion.name] = companionDest;
|
|
291
292
|
}
|
|
292
293
|
|
|
293
|
-
|
|
294
|
+
// A folder document belongs to the pack it is written into, so it carries
|
|
295
|
+
// that pack's system rather than the package-wide one (#48).
|
|
296
|
+
writeFolderDocs(folderList, statsForPack(system, config), dest, type);
|
|
294
297
|
|
|
295
298
|
const pack = new packClass({
|
|
296
299
|
contentBase,
|
|
@@ -311,6 +314,8 @@ async function generatePack(
|
|
|
311
314
|
foreignSourceDirs: foreignItemCatalogDirs(config),
|
|
312
315
|
folderResolver: resolver,
|
|
313
316
|
packName: name,
|
|
317
|
+
// Which system this pack's documents are stamped for (#48).
|
|
318
|
+
packSystem: system ?? null,
|
|
314
319
|
docType: type,
|
|
315
320
|
router,
|
|
316
321
|
routingReporter,
|
package/engine/helpers.mjs
CHANGED
|
@@ -335,6 +335,44 @@ export function buildStats(
|
|
|
335
335
|
};
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
+
/**
|
|
339
|
+
* The `_stats` block for one pack, stamped with the system that pack is for
|
|
340
|
+
* (#48).
|
|
341
|
+
*
|
|
342
|
+
* **`systemId` travels with `systemVersion`.** They are one decision, so where
|
|
343
|
+
* one is omitted both are. Stamping a per-pack version against a package-wide
|
|
344
|
+
* id would emit `systemId: sohl, systemVersion: 1.6.3` on HM3 documents — a
|
|
345
|
+
* *plausible lie*, which is worse than the missing value #43 fixed, because
|
|
346
|
+
* nothing about it looks wrong.
|
|
347
|
+
*
|
|
348
|
+
* Resolution, in order:
|
|
349
|
+
*
|
|
350
|
+
* 1. The pack's own `system:`, looked up in the `systems:` block. That is the
|
|
351
|
+
* case a module shipping for two systems needs, and the one no
|
|
352
|
+
* package-wide value could express.
|
|
353
|
+
* 2. Failing that, the package-wide `stats` — a package whose packs are all for
|
|
354
|
+
* one system, which is every package that worked before this existed.
|
|
355
|
+
*
|
|
356
|
+
* A pack naming a system is validated against `systems:` at configuration time,
|
|
357
|
+
* so an unresolvable name never reaches here.
|
|
358
|
+
*
|
|
359
|
+
* @param {string|null|undefined} packSystem - The pack's declared `system:`.
|
|
360
|
+
* @param {object} [config] - The resolved configuration.
|
|
361
|
+
* @returns {object} The `_stats` block for that pack.
|
|
362
|
+
*/
|
|
363
|
+
export function statsForPack(packSystem, config = loadPackConfig()) {
|
|
364
|
+
const declared = packSystem ? config.systems?.[packSystem] : null;
|
|
365
|
+
if (!declared) return buildStats(undefined, config);
|
|
366
|
+
return {
|
|
367
|
+
systemId: packSystem,
|
|
368
|
+
systemVersion: declared.compatibility.verified,
|
|
369
|
+
coreVersion: supportedCoreVersion(config),
|
|
370
|
+
createdTime: 0,
|
|
371
|
+
modifiedTime: 0,
|
|
372
|
+
lastModifiedBy: config.stats.lastModifiedBy,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
338
376
|
/** Memoised {@link defaultStats}. */
|
|
339
377
|
let cachedDefaultStats;
|
|
340
378
|
|
package/engine/homepage.mjs
CHANGED
|
@@ -49,14 +49,18 @@
|
|
|
49
49
|
*
|
|
50
50
|
* **Its address is the package's, not the note's.** A homepage publishes at
|
|
51
51
|
* `/<contentPackage>/` because that is where the package is, so `name.full`,
|
|
52
|
-
* `shortcode` and `id` decide nothing on it
|
|
53
|
-
*
|
|
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
|
|
54
55
|
* no compendium UUID and appears in no pack and in no link-manifest entry.
|
|
55
56
|
*
|
|
56
57
|
* @module
|
|
57
58
|
*/
|
|
58
59
|
|
|
60
|
+
import fs from "node:fs";
|
|
61
|
+
|
|
59
62
|
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
63
|
+
import { formatLocator, positionInFrontmatter } from "./diagnostics.mjs";
|
|
60
64
|
|
|
61
65
|
/**
|
|
62
66
|
* The note type that compiles to the package homepage.
|
|
@@ -98,6 +102,206 @@ export function isHomepage(fm) {
|
|
|
98
102
|
return Boolean(fm) && fm.type === HOMEPAGE_TYPE;
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
/**
|
|
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*
|
|
127
|
+
* ({@link homepageFrontmatter}), so an unrecognised key is a Hugo or theme
|
|
128
|
+
* parameter this build has never heard of and has no standing to refuse.
|
|
129
|
+
* 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*.
|
|
132
|
+
*
|
|
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.
|
|
137
|
+
*
|
|
138
|
+
* @type {ReadonlyMap<string, string>}
|
|
139
|
+
*/
|
|
140
|
+
export const HOMEPAGE_REFUSED_FIELDS = Object.freeze(
|
|
141
|
+
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
|
+
[
|
|
159
|
+
"id",
|
|
160
|
+
"`id` decides nothing on a `type: homepage` note: it is the " +
|
|
161
|
+
"Foundry document id a compendium UUID is built from, and a " +
|
|
162
|
+
"homepage compiles into no document — it appears in no pack " +
|
|
163
|
+
"and in no link manifest. Delete it",
|
|
164
|
+
],
|
|
165
|
+
]),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The address-bearing fields one note authors, in the order it authored them.
|
|
170
|
+
*
|
|
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.
|
|
174
|
+
*
|
|
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.
|
|
177
|
+
*
|
|
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.
|
|
182
|
+
*
|
|
183
|
+
* @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.
|
|
186
|
+
*/
|
|
187
|
+
export function checkHomepageAddressFields(fm) {
|
|
188
|
+
if (!isHomepage(fm)) return [];
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const key of Object.keys(fm)) {
|
|
191
|
+
const message = HOMEPAGE_REFUSED_FIELDS.get(key);
|
|
192
|
+
if (message) out.push({ key, message });
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Require exactly one homepage note in a content tree (#52).
|
|
199
|
+
*
|
|
200
|
+
* "Exactly one" is two rules, and they are **one severity** because they are
|
|
201
|
+
* one defect: a package whose front page is not the page a person chose.
|
|
202
|
+
*
|
|
203
|
+
* - _None_ and the package serves nothing at `/<package>/`. That is the failure
|
|
204
|
+
* #50 exists to prevent, and it is silent — the site build reports `wrote 0
|
|
205
|
+
* 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.
|
|
212
|
+
*
|
|
213
|
+
* Neither has a safe default, so neither is a warning. A warning is the right
|
|
214
|
+
* severity for something a build can proceed past correctly, and a build that
|
|
215
|
+
* proceeds past either of these publishes the wrong front page while reporting
|
|
216
|
+
* success — which is the exact outcome a warning would be tolerating.
|
|
217
|
+
*
|
|
218
|
+
* **Two is reported once per note, not once for the tree.** Each note is a
|
|
219
|
+
* place an author has to open and edit, and a single finding saying "there are
|
|
220
|
+
* two" sends them hunting for the second.
|
|
221
|
+
*
|
|
222
|
+
* **None is located at the tree, honestly.** There is no file to name, so the
|
|
223
|
+
* locator is the content root — the directory the note is missing from, which
|
|
224
|
+
* is a real path and the one the author adds it to. No line and no column are
|
|
225
|
+
* invented for it, per the diagnostic rules in
|
|
226
|
+
* {@link module:engine/diagnostics}. {@link lintContentTree} already reports an
|
|
227
|
+
* empty walk against the same locator.
|
|
228
|
+
*
|
|
229
|
+
* The rule reads no `site:` configuration and does not vary by
|
|
230
|
+
* `publish.site`: that setting chooses whether the *content* surfaces are
|
|
231
|
+
* published, and the homepage is the floor underneath both modes.
|
|
232
|
+
*
|
|
233
|
+
* @param {ReadonlyArray<{file: string}>} found - The homepage notes, in walk
|
|
234
|
+
* order. Paths may be absolute or relative to the working directory.
|
|
235
|
+
* @param {object} options - Options.
|
|
236
|
+
* @param {string} options.contentBase - Root of the content tree, for the
|
|
237
|
+
* locator when there is no file to name.
|
|
238
|
+
* @param {string} [options.contentPackage] - The package this tree builds.
|
|
239
|
+
* Dropped from the message when unknown rather than guessed.
|
|
240
|
+
* @returns {Array<{file: string, line?: number, column?: number,
|
|
241
|
+
* severity: "error", message: string}>} The findings, one per offending note.
|
|
242
|
+
*/
|
|
243
|
+
export function checkHomepageCount(found, { contentBase, contentPackage }) {
|
|
244
|
+
const pages = found ?? [];
|
|
245
|
+
const named = contentPackage ? ` "${contentPackage}"` : "";
|
|
246
|
+
const address = contentPackage ? ` /${contentPackage}/` : "";
|
|
247
|
+
|
|
248
|
+
if (pages.length === 0) {
|
|
249
|
+
return [
|
|
250
|
+
{
|
|
251
|
+
file: contentBase,
|
|
252
|
+
severity: "error",
|
|
253
|
+
message:
|
|
254
|
+
`holds no \`type: homepage\` note, so ` +
|
|
255
|
+
`${contentPackage ? `package${named}` : "this package"} ` +
|
|
256
|
+
`publishes nothing at its own address${address} — a ` +
|
|
257
|
+
`package's front page is one authored note in this tree, ` +
|
|
258
|
+
`routed by \`type:\` rather than by filename`,
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
if (pages.length === 1) return [];
|
|
263
|
+
|
|
264
|
+
return pages.map((page) => {
|
|
265
|
+
const others = pages
|
|
266
|
+
.filter((p) => p !== page)
|
|
267
|
+
.map((p) => formatLocator({ file: p.file }));
|
|
268
|
+
return {
|
|
269
|
+
file: page.file,
|
|
270
|
+
...positionOfType(page.file),
|
|
271
|
+
severity: "error",
|
|
272
|
+
message:
|
|
273
|
+
`duplicate \`type: homepage\` note, also declared by ` +
|
|
274
|
+
`${others.join(", ")}; a package has one front page` +
|
|
275
|
+
`${contentPackage ? `, at${address},` : ""} and every ` +
|
|
276
|
+
`homepage is written to the same \`${HOMEPAGE_DESTINATION}\` — ` +
|
|
277
|
+
`so the one the walk reaches last silently overwrites the rest`,
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Where a note declares `type: homepage`, when the file can still be read.
|
|
284
|
+
*
|
|
285
|
+
* A separate read rather than a raw text threaded through every caller: the
|
|
286
|
+
* two call sites hold different shapes (a lint note, a collected page) and this
|
|
287
|
+
* runs only on a tree that is already failing.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} file - Path to the note.
|
|
290
|
+
* @returns {{line?: number, column?: number}} Spreadable position fields, empty
|
|
291
|
+
* when the file cannot be read — dropped rather than guessed.
|
|
292
|
+
*/
|
|
293
|
+
function positionOfType(file) {
|
|
294
|
+
try {
|
|
295
|
+
return positionInFrontmatter(
|
|
296
|
+
fs.readFileSync(file, "utf8"),
|
|
297
|
+
"type",
|
|
298
|
+
HOMEPAGE_TYPE,
|
|
299
|
+
);
|
|
300
|
+
} catch {
|
|
301
|
+
return {};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
101
305
|
/**
|
|
102
306
|
* The title a homepage publishes under.
|
|
103
307
|
*
|
package/engine/journals.mjs
CHANGED
|
@@ -230,6 +230,11 @@ export function buildPages(rawPages, entryId, noteName) {
|
|
|
230
230
|
* heading; see {@link splitPages}.
|
|
231
231
|
* @param {string|null} [params.folder] - The folder id, or `null`.
|
|
232
232
|
* @param {object} [params.flags] - Document flags.
|
|
233
|
+
* @param {object} [params.stats] - The `_stats` block to stamp. Passed by the
|
|
234
|
+
* caller because it is a property of the *pack* being written, not of the
|
|
235
|
+
* entry: a module may ship the same content for two systems, and each pack's
|
|
236
|
+
* documents record the system version they were built against (#48). A
|
|
237
|
+
* caller with no pack in hand gets the package-wide block.
|
|
233
238
|
* @returns {object} The JournalEntry document, keyed for the pack.
|
|
234
239
|
*/
|
|
235
240
|
export function buildJournalEntry({
|
|
@@ -239,6 +244,7 @@ export function buildJournalEntry({
|
|
|
239
244
|
leadName,
|
|
240
245
|
folder = null,
|
|
241
246
|
flags,
|
|
247
|
+
stats = defaultStats(),
|
|
242
248
|
}) {
|
|
243
249
|
const rawPages = splitPages(markdown, leadName);
|
|
244
250
|
const pages = buildPages(rawPages, id, name);
|
|
@@ -250,7 +256,7 @@ export function buildJournalEntry({
|
|
|
250
256
|
ownership: { default: 0 },
|
|
251
257
|
flags: flags || {},
|
|
252
258
|
_id: id,
|
|
253
|
-
_stats:
|
|
259
|
+
_stats: stats,
|
|
254
260
|
_key: `!journal!${id}`,
|
|
255
261
|
};
|
|
256
262
|
}
|
|
@@ -347,6 +353,7 @@ export class Journals extends BasePackCompiler {
|
|
|
347
353
|
leadName: ownsDoc ? name : undefined,
|
|
348
354
|
folder,
|
|
349
355
|
flags: fm.flags,
|
|
356
|
+
stats: this.stats,
|
|
350
357
|
});
|
|
351
358
|
}
|
|
352
359
|
|
package/engine/macros.mjs
CHANGED
|
@@ -322,6 +322,8 @@ export class Macros extends BasePackCompiler {
|
|
|
322
322
|
return buildMacroEntry(fm, {
|
|
323
323
|
command: macroCommand(body, name),
|
|
324
324
|
folder: this.folderResolver(sohlField(fm, "folder", null)),
|
|
325
|
+
// This pack's system, not the package-wide one (#48).
|
|
326
|
+
stats: this.stats,
|
|
325
327
|
});
|
|
326
328
|
}
|
|
327
329
|
|