@jxsuite/parser 1.8.1 → 2.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/LICENSE +1 -1
- package/README.md +33 -0
- package/dist/content-loader.js +34124 -62
- package/dist/content.js +119 -3
- package/dist/markdown.js +1113 -96
- package/dist/serialize.js +177 -5
- package/package.json +7 -7
- package/schemas/project.fragment.schema.json +138 -6
- package/src/Content.class.json +6 -3
- package/src/Markdown.class.json +14 -3
- package/src/alerts.ts +257 -0
- package/src/content-links.ts +343 -0
- package/src/content-loader.ts +863 -75
- package/src/content-routes.ts +369 -0
- package/src/content-rules.ts +474 -0
- package/src/content.ts +49 -13
- package/src/grammars/caddyfile.ts +86 -0
- package/src/highlight.ts +49 -9
- package/src/inert.ts +101 -0
- package/src/markdown.ts +43 -17
- package/src/md.ts +29 -6
- package/src/serialize.ts +0 -3
- package/src/transpile.ts +62 -7
- package/src/types.ts +17 -0
- package/src/walk.ts +97 -0
package/src/content-loader.ts
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
21
|
-
import { basename, dirname, extname, resolve } from "node:path";
|
|
21
|
+
import { basename, dirname, extname, relative, resolve } from "node:path";
|
|
22
22
|
import { assetUrlFor } from "@jxsuite/schema/asset-paths";
|
|
23
|
-
import { resolveI18n } from "@jxsuite/schema/locale";
|
|
23
|
+
import { localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
|
|
24
24
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
25
25
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
26
26
|
import type { FormatEntry, FormatHostIO, FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
@@ -30,8 +30,25 @@ import type {
|
|
|
30
30
|
JxMutableNode,
|
|
31
31
|
ProjectConfig,
|
|
32
32
|
} from "@jxsuite/schema/types";
|
|
33
|
+
import { normalizeAlerts } from "./alerts.ts";
|
|
34
|
+
import { rewriteLinks } from "./content-links.ts";
|
|
35
|
+
import type { FileOutcome, LinkIndex } from "./content-links.ts";
|
|
36
|
+
import {
|
|
37
|
+
directoryOf,
|
|
38
|
+
isDirectoryIndex,
|
|
39
|
+
parseRouteConfig,
|
|
40
|
+
renderRoute,
|
|
41
|
+
routeHref,
|
|
42
|
+
routeParams,
|
|
43
|
+
traversalReason,
|
|
44
|
+
} from "./content-routes.ts";
|
|
45
|
+
import type { RouteSpec } from "./content-routes.ts";
|
|
46
|
+
import { compileExclude, compileWhere } from "./content-rules.ts";
|
|
47
|
+
import type { ExcludeMatcher, WherePredicate } from "./content-rules.ts";
|
|
33
48
|
import { coerceEntryDates, isCoercedDate, isDateFormat } from "./dates.ts";
|
|
49
|
+
import type { ContentNodes } from "./inert.ts";
|
|
34
50
|
import type { ContentLoaderEntry, ContentTypeDef } from "./types.ts";
|
|
51
|
+
import { walkFiles } from "./walk.ts";
|
|
35
52
|
|
|
36
53
|
export type { ContentEntry, ContentLoaderEntry, TocEntry } from "./types.ts";
|
|
37
54
|
|
|
@@ -63,6 +80,18 @@ export interface ResolvePathsContext {
|
|
|
63
80
|
* reads it — it is the discriminator between two translations sharing an id.
|
|
64
81
|
*/
|
|
65
82
|
locale?: string | null;
|
|
83
|
+
/**
|
|
84
|
+
* The URL pattern of the dynamic page being expanded (`/kb/:category/:slug`, `/kb/*`) and its
|
|
85
|
+
* parameter names in order. A content type with a `route` reads them to turn each entry's route
|
|
86
|
+
* into the parameters THIS page needs, so `$paths` never has to name a parameter.
|
|
87
|
+
*/
|
|
88
|
+
urlPattern?: string;
|
|
89
|
+
params?: readonly string[];
|
|
90
|
+
/**
|
|
91
|
+
* The URL patterns of every dynamic page in the project, so a routed content type can report the
|
|
92
|
+
* entries no page at all would generate (a route no page shape produces is a dead link).
|
|
93
|
+
*/
|
|
94
|
+
patterns?: readonly string[];
|
|
66
95
|
}
|
|
67
96
|
|
|
68
97
|
/** The `$paths` value shape routed to this extension by its `contentType` discriminator. */
|
|
@@ -101,18 +130,12 @@ function loadJSONEntries(filePath: string): ContentLoaderEntry[] {
|
|
|
101
130
|
];
|
|
102
131
|
}
|
|
103
132
|
|
|
104
|
-
/** Discover .json entry files for a source (single file or directory). */
|
|
105
|
-
function discoverJSONFiles(resolvedSource: string): string[] {
|
|
133
|
+
/** Discover .json entry files for a source (single file or directory), in sorted order. */
|
|
134
|
+
function discoverJSONFiles(resolvedSource: string, exclude: ExcludeMatcher): string[] {
|
|
106
135
|
if (extname(resolvedSource)) {
|
|
107
136
|
return existsSync(resolvedSource) ? [resolvedSource] : [];
|
|
108
137
|
}
|
|
109
|
-
|
|
110
|
-
return readdirSync(resolvedSource, { recursive: true })
|
|
111
|
-
.filter((f) => String(f).endsWith(".json"))
|
|
112
|
-
.map((f) => resolve(resolvedSource, String(f)));
|
|
113
|
-
} catch {
|
|
114
|
-
return [];
|
|
115
|
-
}
|
|
138
|
+
return walkFiles(resolvedSource, (name) => name.toLowerCase().endsWith(".json"), exclude);
|
|
116
139
|
}
|
|
117
140
|
|
|
118
141
|
// ─── Asset mounts and content-relative references ────────────────────────────
|
|
@@ -201,6 +224,44 @@ function isNonRelativeRef(value: string): boolean {
|
|
|
201
224
|
);
|
|
202
225
|
}
|
|
203
226
|
|
|
227
|
+
/** Document extensions: an entry of a collection is never an asset of it. */
|
|
228
|
+
const DOCUMENT_EXTENSIONS = new Set([".md", ".markdown", ".mdx"]);
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* What a content type's asset mount may serve, or `undefined` for "everything under the directory"
|
|
232
|
+
* (today's behavior, which every type that declares none of the vault options keeps).
|
|
233
|
+
*
|
|
234
|
+
* A type that declares `exclude`, `where` or `route` is publishing a folder of notes, and there the
|
|
235
|
+
* mount is the collection's front door, so it must not be a way around the collection's own rules:
|
|
236
|
+
* a file `exclude` names is not served however a page refers to it, nothing whose path has a dot
|
|
237
|
+
* segment is (`.git`, `.env` and `.obsidian` are what a source set to the repository root also
|
|
238
|
+
* contains), and a document is not (a note `where` left out would otherwise leave as a raw
|
|
239
|
+
* download, frontmatter and all). The paths it is asked about are relative to the mount directory,
|
|
240
|
+
* which is the source directory.
|
|
241
|
+
*/
|
|
242
|
+
function mountFilterFor(def: ContentTypeDef | undefined): ((path: string) => boolean) | undefined {
|
|
243
|
+
if (!def || (def.exclude === undefined && def.where === undefined && def.route === undefined)) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
let exclude: ExcludeMatcher;
|
|
247
|
+
try {
|
|
248
|
+
exclude = compileExclude(def.exclude);
|
|
249
|
+
} catch {
|
|
250
|
+
// The same list fails the build when the type loads; until then, serve nothing.
|
|
251
|
+
return () => false;
|
|
252
|
+
}
|
|
253
|
+
return (path) => {
|
|
254
|
+
const segments = path.split("/");
|
|
255
|
+
if (segments.some((segment) => segment.startsWith("."))) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
if (DOCUMENT_EXTENSIONS.has(extname(path).toLowerCase())) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
return exclude.excludedBy(path) === undefined;
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
204
265
|
/**
|
|
205
266
|
* Asset mounts for a `content` section: every content type whose source is a local **directory**
|
|
206
267
|
* publishes that directory at `/content/<type>`, so files sitting beside the entries (images,
|
|
@@ -218,6 +279,10 @@ export function contentAssetMounts(
|
|
|
218
279
|
): AssetMount[] {
|
|
219
280
|
const mounts: AssetMount[] = [];
|
|
220
281
|
const locales = localesForExpansion(projectConfig);
|
|
282
|
+
const filterOf = (def: ContentTypeDef | undefined): { filter?: (path: string) => boolean } => {
|
|
283
|
+
const filter = mountFilterFor(def);
|
|
284
|
+
return filter === undefined ? {} : { filter };
|
|
285
|
+
};
|
|
221
286
|
for (const [name, contentTypeDef] of Object.entries(section ?? {})) {
|
|
222
287
|
const source = contentTypeDef?.source;
|
|
223
288
|
if (!source || source.startsWith("http://") || source.startsWith("https://")) {
|
|
@@ -237,7 +302,11 @@ export function contentAssetMounts(
|
|
|
237
302
|
.split("\\")
|
|
238
303
|
.join("/");
|
|
239
304
|
if (SAFE_TYPE_NAME.test(name) && existsSync(dir) && statSync(dir).isDirectory()) {
|
|
240
|
-
mounts.push({
|
|
305
|
+
mounts.push({
|
|
306
|
+
dir,
|
|
307
|
+
urlPrefix: `/${SECTION_KEY}/${name}/${locale}`,
|
|
308
|
+
...filterOf(contentTypeDef),
|
|
309
|
+
});
|
|
241
310
|
}
|
|
242
311
|
}
|
|
243
312
|
continue;
|
|
@@ -253,22 +322,31 @@ export function contentAssetMounts(
|
|
|
253
322
|
if (extname(source) || !existsSync(resolvedSource) || !statSync(resolvedSource).isDirectory()) {
|
|
254
323
|
continue;
|
|
255
324
|
}
|
|
256
|
-
mounts.push({
|
|
325
|
+
mounts.push({
|
|
326
|
+
dir: resolvedSource,
|
|
327
|
+
urlPrefix: `/${SECTION_KEY}/${name}`,
|
|
328
|
+
...filterOf(contentTypeDef),
|
|
329
|
+
});
|
|
257
330
|
}
|
|
258
331
|
return mounts;
|
|
259
332
|
}
|
|
260
333
|
|
|
334
|
+
/** Why a content-relative reference was left as written. */
|
|
335
|
+
type RefProblem = "missing" | "refused";
|
|
336
|
+
|
|
261
337
|
/**
|
|
262
338
|
* Rewrite one content-relative reference to its mounted URL, or null to leave the value untouched.
|
|
263
339
|
* A value is rewritten only when it is relative, resolves against the entry's own directory to a
|
|
264
|
-
* file that exists, and lands inside the mount — so nothing an existing
|
|
265
|
-
* project-root-relative path, an absolute URL, a bound template) changes
|
|
340
|
+
* file that exists, and lands inside the mount and where the mount serves — so nothing an existing
|
|
341
|
+
* project authored (a project-root-relative path, an absolute URL, a bound template) changes
|
|
342
|
+
* meaning. A file inside the collection that its mount refuses to serve (excluded, hidden or a
|
|
343
|
+
* document) is reported and left as written, so a published note cannot pull it into the site.
|
|
266
344
|
*/
|
|
267
345
|
function rewriteRef(
|
|
268
346
|
value: string,
|
|
269
347
|
entryDir: string,
|
|
270
348
|
mount: AssetMount,
|
|
271
|
-
|
|
349
|
+
onProblem: (value: string, problem: RefProblem) => void,
|
|
272
350
|
): string | null {
|
|
273
351
|
if (isNonRelativeRef(value)) {
|
|
274
352
|
return null;
|
|
@@ -282,10 +360,13 @@ function rewriteRef(
|
|
|
282
360
|
}
|
|
283
361
|
const url = assetUrlFor([mount], target);
|
|
284
362
|
if (!url) {
|
|
285
|
-
|
|
363
|
+
if (relativeTo(mount.dir, target) !== null && existsSync(target)) {
|
|
364
|
+
onProblem(value, "refused");
|
|
365
|
+
}
|
|
366
|
+
return null; // Resolves outside the collection, or is not served — not ours to publish
|
|
286
367
|
}
|
|
287
368
|
if (!existsSync(target)) {
|
|
288
|
-
|
|
369
|
+
onProblem(value, "missing");
|
|
289
370
|
return null;
|
|
290
371
|
}
|
|
291
372
|
const suffix = value.slice(bare.length);
|
|
@@ -294,11 +375,17 @@ function rewriteRef(
|
|
|
294
375
|
|
|
295
376
|
/** Rewrite media references on one node and its children, in place. */
|
|
296
377
|
function rewriteNodeAssets(
|
|
297
|
-
node: JxElement | string | undefined,
|
|
378
|
+
node: JxElement | string | ContentNodes | undefined,
|
|
298
379
|
entryDir: string,
|
|
299
380
|
mount: AssetMount,
|
|
300
|
-
|
|
381
|
+
onProblem: (value: string, problem: RefProblem) => void,
|
|
301
382
|
) {
|
|
383
|
+
if (Array.isArray(node)) {
|
|
384
|
+
for (const child of node) {
|
|
385
|
+
rewriteNodeAssets(child, entryDir, mount, onProblem);
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
302
389
|
if (!node || typeof node !== "object") {
|
|
303
390
|
return;
|
|
304
391
|
}
|
|
@@ -308,7 +395,7 @@ function rewriteNodeAssets(
|
|
|
308
395
|
for (const holder of [record, attributes]) {
|
|
309
396
|
const value = holder?.[key];
|
|
310
397
|
if (typeof value === "string") {
|
|
311
|
-
const rewritten = rewriteRef(value, entryDir, mount,
|
|
398
|
+
const rewritten = rewriteRef(value, entryDir, mount, onProblem);
|
|
312
399
|
if (rewritten) {
|
|
313
400
|
holder![key] = rewritten;
|
|
314
401
|
}
|
|
@@ -318,7 +405,7 @@ function rewriteNodeAssets(
|
|
|
318
405
|
const { children } = record;
|
|
319
406
|
if (Array.isArray(children)) {
|
|
320
407
|
for (const child of children) {
|
|
321
|
-
rewriteNodeAssets(child as JxElement | string, entryDir, mount,
|
|
408
|
+
rewriteNodeAssets(child as JxElement | string | ContentNodes, entryDir, mount, onProblem);
|
|
322
409
|
}
|
|
323
410
|
}
|
|
324
411
|
}
|
|
@@ -351,22 +438,26 @@ export function rewriteEntryAssets(
|
|
|
351
438
|
.map(([field]) => field);
|
|
352
439
|
|
|
353
440
|
for (const entry of entries) {
|
|
354
|
-
const
|
|
441
|
+
const onProblem = (value: string, problem: RefProblem) =>
|
|
355
442
|
console.warn(
|
|
356
|
-
|
|
443
|
+
problem === "missing"
|
|
444
|
+
? `Content type "${typeName}": entry "${entry.id}" references missing asset "${value}"`
|
|
445
|
+
: `Content type "${typeName}": entry "${entry.id}" references "${value}", which the ` +
|
|
446
|
+
`collection does not publish (it is excluded, hidden or a document), so the ` +
|
|
447
|
+
`reference is left as written`,
|
|
357
448
|
);
|
|
358
449
|
|
|
359
450
|
for (const child of entry.$children ?? []) {
|
|
360
|
-
rewriteNodeAssets(child, entryDir, mount,
|
|
451
|
+
rewriteNodeAssets(child, entryDir, mount, onProblem);
|
|
361
452
|
}
|
|
362
453
|
|
|
363
454
|
for (const field of uriFields) {
|
|
364
455
|
const value = entry.data[field];
|
|
365
456
|
if (typeof value === "string") {
|
|
366
|
-
entry.data[field] = rewriteRef(value, entryDir, mount,
|
|
457
|
+
entry.data[field] = rewriteRef(value, entryDir, mount, onProblem) ?? value;
|
|
367
458
|
} else if (Array.isArray(value)) {
|
|
368
459
|
entry.data[field] = value.map((item) =>
|
|
369
|
-
typeof item === "string" ? (rewriteRef(item, entryDir, mount,
|
|
460
|
+
typeof item === "string" ? (rewriteRef(item, entryDir, mount, onProblem) ?? item) : item,
|
|
370
461
|
);
|
|
371
462
|
}
|
|
372
463
|
}
|
|
@@ -425,7 +516,10 @@ export async function loadContentSection(
|
|
|
425
516
|
for (const [name, contentTypeDef] of Object.entries(section)) {
|
|
426
517
|
const source = contentTypeDef?.source;
|
|
427
518
|
if (typeof source !== "string" || !source.includes(LOCALE_PLACEHOLDER)) {
|
|
428
|
-
const entries = await loadContentType(name, contentTypeDef, root, formats,
|
|
519
|
+
const entries = await loadContentType(name, contentTypeDef, root, formats, {
|
|
520
|
+
mount: mounts.get(name),
|
|
521
|
+
projectConfig,
|
|
522
|
+
});
|
|
429
523
|
contentTypes.set(name, entries);
|
|
430
524
|
continue;
|
|
431
525
|
}
|
|
@@ -445,13 +539,11 @@ export async function loadContentSection(
|
|
|
445
539
|
const all: ContentLoaderEntry[] = [];
|
|
446
540
|
for (const locale of locales) {
|
|
447
541
|
const localized = { ...contentTypeDef, source: localeSource(root, source, locale) };
|
|
448
|
-
const entries = await loadContentType(
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
mounts.get(`${name}/${locale}`),
|
|
454
|
-
);
|
|
542
|
+
const entries = await loadContentType(name, localized, root, formats, {
|
|
543
|
+
locale,
|
|
544
|
+
mount: mounts.get(`${name}/${locale}`),
|
|
545
|
+
projectConfig,
|
|
546
|
+
});
|
|
455
547
|
for (const entry of entries) {
|
|
456
548
|
entry._meta = { ...entry._meta, locale };
|
|
457
549
|
}
|
|
@@ -481,6 +573,526 @@ export function getContentTypeElements(
|
|
|
481
573
|
return def?.$elements;
|
|
482
574
|
}
|
|
483
575
|
|
|
576
|
+
// ─── Per-type rules ──────────────────────────────────────────────────────────
|
|
577
|
+
|
|
578
|
+
/** How a link that has no page is reported (`links`). */
|
|
579
|
+
type LinkMode = "warn" | "error" | "ignore";
|
|
580
|
+
|
|
581
|
+
/** A content type's declarative options, validated once per load. */
|
|
582
|
+
interface TypeRules {
|
|
583
|
+
exclude: ExcludeMatcher;
|
|
584
|
+
where: WherePredicate | null;
|
|
585
|
+
idField: string | undefined;
|
|
586
|
+
route: RouteSpec | null;
|
|
587
|
+
links: LinkMode;
|
|
588
|
+
alerts: unknown;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Every key a content type's definition means something by. */
|
|
592
|
+
const KNOWN_TYPE_KEYS = [
|
|
593
|
+
"$elements",
|
|
594
|
+
"alerts",
|
|
595
|
+
"exclude",
|
|
596
|
+
"format",
|
|
597
|
+
"idField",
|
|
598
|
+
"indexRoute",
|
|
599
|
+
"links",
|
|
600
|
+
"route",
|
|
601
|
+
"schema",
|
|
602
|
+
"source",
|
|
603
|
+
"where",
|
|
604
|
+
] as const;
|
|
605
|
+
|
|
606
|
+
/** Edit distance between two short strings, for the "did you mean" of a misspelled option. */
|
|
607
|
+
function editDistance(a: string, b: string): number {
|
|
608
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
609
|
+
for (let i = 1; i <= a.length; i++) {
|
|
610
|
+
const row = [i];
|
|
611
|
+
for (let j = 1; j <= b.length; j++) {
|
|
612
|
+
row[j] = Math.min(
|
|
613
|
+
row[j - 1]! + 1,
|
|
614
|
+
previous[j]! + 1,
|
|
615
|
+
previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
previous = row;
|
|
619
|
+
}
|
|
620
|
+
return previous[b.length]!;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The option a misspelled key most plausibly meant, or undefined when none is close.
|
|
625
|
+
*
|
|
626
|
+
* The options exist to keep things OUT of a published site, so a typo in one fails open: `excludes`
|
|
627
|
+
* would be ignored and the private folder it was meant to hide would be published. That is why a
|
|
628
|
+
* near miss is a build error and not a note.
|
|
629
|
+
*/
|
|
630
|
+
function nearestOption(key: string): string | undefined {
|
|
631
|
+
const lower = key.toLowerCase();
|
|
632
|
+
let best: string | undefined;
|
|
633
|
+
let bestDistance = 3;
|
|
634
|
+
for (const known of KNOWN_TYPE_KEYS) {
|
|
635
|
+
const distance = editDistance(lower, known.toLowerCase());
|
|
636
|
+
if (distance < bestDistance) {
|
|
637
|
+
best = known;
|
|
638
|
+
bestDistance = distance;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return best;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Validate and compile a content type's `exclude`, `where`, `idField`, `route`, `links` and
|
|
646
|
+
* `alerts`. A malformed option is a build failure that names the content type, never a silently
|
|
647
|
+
* ignored key: `where: { publish: "yes" }` that quietly matched nothing would publish an empty site
|
|
648
|
+
* and say nothing, and neither would `excludes` (for `exclude`) say it was being ignored.
|
|
649
|
+
*/
|
|
650
|
+
function compileTypeRules(name: string, def: ContentTypeDef): TypeRules {
|
|
651
|
+
const at = `Content type "${name}"`;
|
|
652
|
+
try {
|
|
653
|
+
for (const key of Object.keys(def)) {
|
|
654
|
+
if ((KNOWN_TYPE_KEYS as readonly string[]).includes(key) || key === "$comment") {
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const suggestion = nearestOption(key);
|
|
658
|
+
if (suggestion !== undefined) {
|
|
659
|
+
throw new TypeError(`unknown option "${key}", did you mean "${suggestion}"?`);
|
|
660
|
+
}
|
|
661
|
+
console.warn(
|
|
662
|
+
`${at}: ignores the unknown option "${key}" (the options are ${KNOWN_TYPE_KEYS.join(", ")})`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
if (def.idField !== undefined && (typeof def.idField !== "string" || def.idField === "")) {
|
|
666
|
+
throw new TypeError(`"idField" must be a field name`);
|
|
667
|
+
}
|
|
668
|
+
const links = def.links ?? "warn";
|
|
669
|
+
if (links !== "warn" && links !== "error" && links !== "ignore") {
|
|
670
|
+
throw new TypeError(
|
|
671
|
+
`"links" must be "warn", "error" or "ignore", got ${JSON.stringify(links)}`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
normalizeAlerts(def.alerts, `"alerts"`);
|
|
675
|
+
return {
|
|
676
|
+
alerts: def.alerts,
|
|
677
|
+
exclude: compileExclude(def.exclude),
|
|
678
|
+
idField: def.idField,
|
|
679
|
+
links,
|
|
680
|
+
route: parseRouteConfig(def.route, def.indexRoute, name),
|
|
681
|
+
where: def.where === undefined ? null : compileWhere(def.where),
|
|
682
|
+
};
|
|
683
|
+
} catch (error) {
|
|
684
|
+
const message = errorMessage(error);
|
|
685
|
+
throw new Error(message.startsWith(at) ? message : `${at}: ${message}`, { cause: error });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** The path of a file relative to a source directory, `/`-separated, or null when it is outside. */
|
|
690
|
+
function relativeTo(root: string, file: string): string | null {
|
|
691
|
+
const rel = relative(root, file).split("\\").join("/");
|
|
692
|
+
return rel === "" || rel === ".." || rel.startsWith("../") ? null : rel;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** One source file and the entries it yielded, before the collection-wide rules run. */
|
|
696
|
+
interface LoadedFile {
|
|
697
|
+
filePath: string;
|
|
698
|
+
/** Relative to the source directory; null for a single-file or remote source. */
|
|
699
|
+
rel: string | null;
|
|
700
|
+
entries: ContentLoaderEntry[];
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Everything {@link finishEntries} needs that is not the entries themselves. */
|
|
704
|
+
interface FinishContext {
|
|
705
|
+
name: string;
|
|
706
|
+
schema: ContentTypeSchema | undefined;
|
|
707
|
+
rules: TypeRules;
|
|
708
|
+
mount: AssetMount | undefined;
|
|
709
|
+
projectConfig: ProjectConfig | undefined;
|
|
710
|
+
locale: string | undefined;
|
|
711
|
+
/** Whether the format branch's date-coercion pass applies (it does not for JSON and remote). */
|
|
712
|
+
coerceDates: boolean;
|
|
713
|
+
/** Extensions of the collection's entry files, for link resolution. */
|
|
714
|
+
entryExtensions: Set<string>;
|
|
715
|
+
/** The source directory, when there is one. */
|
|
716
|
+
sourceRoot: string | undefined;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* The collection-wide pass every source kind goes through once its files are loaded.
|
|
721
|
+
*
|
|
722
|
+
* Order matters and is the whole design. `where` runs first, so an entry that is not published is
|
|
723
|
+
* never validated, coerced, given a route or counted as a duplicate. Ids are settled before
|
|
724
|
+
* anything keyed by id (relationships, duplicate checks, routes that read `{id}`). Routes are
|
|
725
|
+
* computed before links, because a link can only be resolved once the page it points at has a URL.
|
|
726
|
+
*
|
|
727
|
+
* @returns {ContentLoaderEntry[]} The kept entries, in load order
|
|
728
|
+
*/
|
|
729
|
+
function finishEntries(files: LoadedFile[], ctx: FinishContext): ContentLoaderEntry[] {
|
|
730
|
+
const { name, rules } = ctx;
|
|
731
|
+
|
|
732
|
+
// 1. where: split every file's entries into kept and left out.
|
|
733
|
+
const kept: LoadedFile[] = [];
|
|
734
|
+
const unpublished = new Map<string, string>();
|
|
735
|
+
let loadedCount = 0;
|
|
736
|
+
for (const file of files) {
|
|
737
|
+
const keep: ContentLoaderEntry[] = [];
|
|
738
|
+
for (const entry of file.entries) {
|
|
739
|
+
loadedCount += 1;
|
|
740
|
+
if (rules.where && !rules.where(entry.data)) {
|
|
741
|
+
if (file.rel !== null && file.entries.length === 1) {
|
|
742
|
+
unpublished.set(file.rel, `left out by where.${rules.where.why(entry.data) ?? "filter"}`);
|
|
743
|
+
}
|
|
744
|
+
} else {
|
|
745
|
+
keep.push(entry);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (keep.length > 0) {
|
|
749
|
+
kept.push({ ...file, entries: keep });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/* A filter that removes every document is nearly always a mistake in the filter (`publish: "true"`
|
|
753
|
+
against a YAML boolean), and the result is an empty site that builds without complaint. */
|
|
754
|
+
if (rules.where && loadedCount > 0 && kept.length === 0) {
|
|
755
|
+
const [first] = files.flatMap((file) => file.entries);
|
|
756
|
+
console.warn(
|
|
757
|
+
`Content type "${name}": "where" left out all ${loadedCount} entries, so the collection is ` +
|
|
758
|
+
`empty (it failed on "${first ? (rules.where.why(first.data) ?? "?") : "?"}" for the first one). ` +
|
|
759
|
+
`Check the field names and the value types: YAML \`true\` is a boolean, "true" is text.`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// 2. Per kept entry: where it came from, and content-relative assets onto the mount.
|
|
764
|
+
for (const file of kept) {
|
|
765
|
+
for (const entry of file.entries) {
|
|
766
|
+
if (file.rel !== null) {
|
|
767
|
+
entry._meta = { ...entry._meta, path: file.rel };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (ctx.mount) {
|
|
771
|
+
rewriteEntryAssets(file.entries, file.filePath, ctx.mount, name, ctx.schema);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const entries = kept.flatMap((file) => file.entries);
|
|
775
|
+
|
|
776
|
+
// 3. Ids: a README beside an index, then the frontmatter field the type names, then duplicates.
|
|
777
|
+
settleIndexIds(name, entries);
|
|
778
|
+
if (rules.idField) {
|
|
779
|
+
for (const entry of entries) {
|
|
780
|
+
const value = entry.data[rules.idField];
|
|
781
|
+
if ((typeof value === "string" && value !== "") || typeof value === "number") {
|
|
782
|
+
/* An id becomes a directory name under the output (`$paths` hands it to a page), and a
|
|
783
|
+
frontmatter value is not trusted to stay inside it. */
|
|
784
|
+
const unsafe = traversalReason(String(value));
|
|
785
|
+
if (unsafe === null) {
|
|
786
|
+
entry.id = String(value);
|
|
787
|
+
} else {
|
|
788
|
+
console.warn(
|
|
789
|
+
`Content ids: "${name}/${entry.id}" has a "${rules.idField}" that ${unsafe}, ` +
|
|
790
|
+
`so it keeps its path id`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
} else {
|
|
794
|
+
console.warn(
|
|
795
|
+
`Content ids: "${name}/${entry.id}" has no usable "${rules.idField}" field, ` +
|
|
796
|
+
`so it keeps its path id`,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const byId = new Map<string, ContentLoaderEntry>();
|
|
802
|
+
for (const entry of entries) {
|
|
803
|
+
const prior = byId.get(entry.id);
|
|
804
|
+
if (prior) {
|
|
805
|
+
console.warn(
|
|
806
|
+
`Content ids: "${name}" has two entries with the id "${entry.id}" ` +
|
|
807
|
+
`(${prior._meta?.path ?? "an earlier entry"} and ${entry._meta?.path ?? "a later entry"}). ` +
|
|
808
|
+
`A lookup finds only the first; give one of them a distinct ${rules.idField ? `"${rules.idField}"` : "name"}.`,
|
|
809
|
+
);
|
|
810
|
+
} else {
|
|
811
|
+
byId.set(entry.id, entry);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 4. Dates, then validation: kept entries only.
|
|
816
|
+
if (ctx.schema) {
|
|
817
|
+
if (ctx.coerceDates) {
|
|
818
|
+
for (const warning of coerceEntryDates(entries, ctx.schema, name)) {
|
|
819
|
+
console.warn(warning.message);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
validateEntries(entries, ctx.schema, name);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// 5. Routes, and links over the finished routes.
|
|
826
|
+
if (rules.route) {
|
|
827
|
+
assignRoutes(entries, unpublished, ctx, rules.route);
|
|
828
|
+
if (ctx.sourceRoot) {
|
|
829
|
+
resolveEntryLinks(kept, unpublished, ctx);
|
|
830
|
+
}
|
|
831
|
+
/* An entry the template could not route, or whose route another entry holds, has no page, and
|
|
832
|
+
a collection is what every consumer (a listing, the search index, a feed) publishes from. Left
|
|
833
|
+
in, each of them would invent a URL for it from its own idea of one and point readers at a
|
|
834
|
+
404, and the document the build calls unpublished would be in the public search index. The
|
|
835
|
+
link index above already knows why it has no page; the collection does not carry it. */
|
|
836
|
+
return entries.filter((entry) => entry._meta?.route !== undefined);
|
|
837
|
+
}
|
|
838
|
+
return entries;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Keep a folder's id for its `index` file when its `README` would also claim it.
|
|
843
|
+
*
|
|
844
|
+
* A README maps to its folder (`Frappe/README.md` is `Frappe`), and so has `index.md` since before
|
|
845
|
+
* READMEs did. A folder holding both used to give them two ids and now would give them one, with
|
|
846
|
+
* whichever sorted first (README, by case) silently winning an `index.md` that is older and means
|
|
847
|
+
* the same thing. The index keeps the folder's id, as links and routes already assume, and the
|
|
848
|
+
* README keeps the id it had before the mapping existed.
|
|
849
|
+
*/
|
|
850
|
+
function settleIndexIds(name: string, entries: ContentLoaderEntry[]): void {
|
|
851
|
+
const indexFolders = new Set<string>();
|
|
852
|
+
for (const entry of entries) {
|
|
853
|
+
const path = entry._meta?.path;
|
|
854
|
+
if (path !== undefined && isDirectoryIndex(path) && !/^readme\./i.test(basename(path))) {
|
|
855
|
+
indexFolders.add(directoryOf(path));
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
for (const entry of entries) {
|
|
859
|
+
const path = entry._meta?.path;
|
|
860
|
+
if (path === undefined || !/^readme\./i.test(basename(path))) {
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
const folder = directoryOf(path);
|
|
864
|
+
if (folder !== "" && entry.id === folder && indexFolders.has(folder)) {
|
|
865
|
+
entry.id = path.slice(0, path.length - extname(path).length);
|
|
866
|
+
console.warn(
|
|
867
|
+
`Content ids: "${name}" has both "${path}" and an index file in "${folder}"; the index ` +
|
|
868
|
+
`keeps the id "${folder}" and the README is "${entry.id}".`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** The site's `build.trailingSlash`, defaulting to `"always"` exactly as the build does. */
|
|
875
|
+
function trailingSlashOf(projectConfig: ProjectConfig | undefined): string {
|
|
876
|
+
const value = projectConfig?.build?.trailingSlash;
|
|
877
|
+
return typeof value === "string" ? value : "always";
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** The URL prefix a collection's locale puts in front of its routes (`/fr`), or `""`. */
|
|
881
|
+
function localePrefixOf(ctx: FinishContext): string {
|
|
882
|
+
const { i18n } = resolveI18n(ctx.projectConfig ?? {});
|
|
883
|
+
return localeUrlPrefix(ctx.locale, i18n);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Stamp `_meta.route` and `_meta.url` on every entry whose route template renders.
|
|
888
|
+
*
|
|
889
|
+
* An entry the template cannot render (a missing `category`) and an entry whose route another
|
|
890
|
+
* already holds are both left without a route and reported with the file names involved: the second
|
|
891
|
+
* would otherwise overwrite the first's page, or hand a link to the wrong document. Both are
|
|
892
|
+
* recorded as unpublished so a link to one says why it has no page.
|
|
893
|
+
*/
|
|
894
|
+
function assignRoutes(
|
|
895
|
+
entries: ContentLoaderEntry[],
|
|
896
|
+
unpublished: Map<string, string>,
|
|
897
|
+
ctx: FinishContext,
|
|
898
|
+
spec: RouteSpec,
|
|
899
|
+
): void {
|
|
900
|
+
const trailingSlash = trailingSlashOf(ctx.projectConfig);
|
|
901
|
+
const prefix = localePrefixOf(ctx);
|
|
902
|
+
const owner = new Map<string, string>();
|
|
903
|
+
/* One line per entry says what is wrong with it, until there are enough that one line per entry
|
|
904
|
+
is a wall: a template naming a field no document has (`{slugg}`) fails for every entry with the
|
|
905
|
+
same reason, and naming the reason once with a count and a few examples is the useful report. */
|
|
906
|
+
const unrouted = new Map<string, { id: string; path: string }[]>();
|
|
907
|
+
for (const entry of entries) {
|
|
908
|
+
const path = entry._meta?.path ?? entry.id;
|
|
909
|
+
const rendered = renderRoute(spec, { data: entry.data, id: entry.id, path });
|
|
910
|
+
if ("error" in rendered) {
|
|
911
|
+
unrouted.set(rendered.error, [
|
|
912
|
+
...(unrouted.get(rendered.error) ?? []),
|
|
913
|
+
{ id: entry.id, path },
|
|
914
|
+
]);
|
|
915
|
+
unpublished.set(path, "it has no route");
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
const prior = owner.get(rendered.route);
|
|
919
|
+
if (prior !== undefined) {
|
|
920
|
+
console.warn(
|
|
921
|
+
`Content routes: "${ctx.name}" has two entries at ${rendered.route} (${prior} and ${path}). ` +
|
|
922
|
+
`Only ${prior} is routed; make the route unique, for example by adding a field to the template.`,
|
|
923
|
+
);
|
|
924
|
+
unpublished.set(path, `its route ${rendered.route} is already used by ${prior}`);
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
owner.set(rendered.route, path);
|
|
928
|
+
entry._meta = {
|
|
929
|
+
...entry._meta,
|
|
930
|
+
route: rendered.route,
|
|
931
|
+
url: routeHref(rendered.route, trailingSlash, prefix),
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
for (const [reason, list] of unrouted) {
|
|
935
|
+
if (list.length <= UNROUTED_LISTED) {
|
|
936
|
+
for (const { id, path } of list) {
|
|
937
|
+
console.warn(`Content routes: "${ctx.name}/${id}" (${path}) has no route: ${reason}`);
|
|
938
|
+
}
|
|
939
|
+
} else {
|
|
940
|
+
const shown = list.slice(0, UNROUTED_LISTED).map(({ path }) => path);
|
|
941
|
+
console.warn(
|
|
942
|
+
`Content routes: "${ctx.name}": ${list.length} entries have no route: ${reason} ` +
|
|
943
|
+
`(for example ${shown.join(", ")})`,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** How many entries a "has no route" report lists by name before it summarizes. */
|
|
950
|
+
const UNROUTED_LISTED = 3;
|
|
951
|
+
|
|
952
|
+
/** One broken link, for the error-mode summary. */
|
|
953
|
+
interface BrokenLink {
|
|
954
|
+
from: string;
|
|
955
|
+
href: string;
|
|
956
|
+
reason: string;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Rewrite the relative links of every kept entry to the routes of the entries they name.
|
|
961
|
+
*
|
|
962
|
+
* Builds the {@link LinkIndex} the resolver needs from what the loader knows (every file it loaded,
|
|
963
|
+
* which of them are published, which directory each README or index stands for), then reports each
|
|
964
|
+
* link that has no page at the severity `links` asks for. In `error` mode all of them are gathered
|
|
965
|
+
* and thrown together, so one build names every broken link instead of one per run.
|
|
966
|
+
*/
|
|
967
|
+
function resolveEntryLinks(
|
|
968
|
+
kept: LoadedFile[],
|
|
969
|
+
unpublished: Map<string, string>,
|
|
970
|
+
ctx: FinishContext,
|
|
971
|
+
): void {
|
|
972
|
+
const { name, rules, sourceRoot } = ctx;
|
|
973
|
+
const files = new Map<string, FileOutcome>();
|
|
974
|
+
const indexes = new Map<string, string>();
|
|
975
|
+
const isReadme = (rel: string) => /^readme\./i.test(rel.slice(rel.lastIndexOf("/") + 1));
|
|
976
|
+
const noteIndex = (rel: string) => {
|
|
977
|
+
const dir = directoryOf(rel);
|
|
978
|
+
const prior = indexes.get(dir);
|
|
979
|
+
// `index` beats `README` when a directory has both, matching how ids are derived.
|
|
980
|
+
if (prior === undefined || (isReadme(prior) && !isReadme(rel))) {
|
|
981
|
+
indexes.set(dir, rel);
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
for (const file of kept) {
|
|
985
|
+
const [entry] = file.entries;
|
|
986
|
+
if (file.rel === null || file.entries.length !== 1 || !entry) {
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
const route = entry._meta?.route;
|
|
990
|
+
files.set(
|
|
991
|
+
file.rel,
|
|
992
|
+
route === undefined
|
|
993
|
+
? { unpublished: unpublished.get(file.rel) ?? "it has no route" }
|
|
994
|
+
: { route },
|
|
995
|
+
);
|
|
996
|
+
if (isDirectoryIndex(file.rel)) {
|
|
997
|
+
noteIndex(file.rel);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
for (const [rel, reason] of unpublished) {
|
|
1001
|
+
if (!files.has(rel)) {
|
|
1002
|
+
files.set(rel, { unpublished: reason });
|
|
1003
|
+
if (isDirectoryIndex(rel)) {
|
|
1004
|
+
noteIndex(rel);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const onDisk = (path: string) => resolve(sourceRoot!, path);
|
|
1010
|
+
const index: LinkIndex = {
|
|
1011
|
+
asset: (path) => {
|
|
1012
|
+
const absolute = onDisk(path);
|
|
1013
|
+
if (!ctx.mount || !existsSync(absolute) || statSync(absolute).isDirectory()) {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const pattern = rules.exclude.excludedBy(path);
|
|
1017
|
+
if (pattern !== undefined) {
|
|
1018
|
+
return { unpublished: `excluded by "${pattern}"` };
|
|
1019
|
+
}
|
|
1020
|
+
const url = assetUrlFor([ctx.mount], absolute);
|
|
1021
|
+
return url === null ? { unpublished: "the collection does not serve a file there" } : { url };
|
|
1022
|
+
},
|
|
1023
|
+
entryExtensions: ctx.entryExtensions,
|
|
1024
|
+
excludedBy: (path) => rules.exclude.excludedBy(path),
|
|
1025
|
+
excludesDir: (path) => rules.exclude.excludesDir(path),
|
|
1026
|
+
exists: (path) => existsSync(onDisk(path)),
|
|
1027
|
+
files,
|
|
1028
|
+
indexes,
|
|
1029
|
+
isDirectory: (path) => {
|
|
1030
|
+
try {
|
|
1031
|
+
return statSync(onDisk(path)).isDirectory();
|
|
1032
|
+
} catch {
|
|
1033
|
+
return false;
|
|
1034
|
+
}
|
|
1035
|
+
},
|
|
1036
|
+
localePrefix: localePrefixOf(ctx),
|
|
1037
|
+
trailingSlash: trailingSlashOf(ctx.projectConfig),
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
const broken: BrokenLink[] = [];
|
|
1041
|
+
const seen = new Set<string>();
|
|
1042
|
+
for (const file of kept) {
|
|
1043
|
+
for (const entry of file.entries) {
|
|
1044
|
+
if (!entry.$children || file.rel === null) {
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
rewriteLinks(entry.$children as ContentNodes, file.rel, index, (problem) => {
|
|
1048
|
+
const key = `${file.rel}\0${problem.href}`;
|
|
1049
|
+
if (!seen.has(key)) {
|
|
1050
|
+
seen.add(key);
|
|
1051
|
+
broken.push({ from: file.rel!, href: problem.href, reason: problem.reason });
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
if (rules.links === "ignore" || broken.length === 0) {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
const lines = broken.map(
|
|
1060
|
+
({ from, href, reason }) => `"${from}" links to "${href}", which ${reason}`,
|
|
1061
|
+
);
|
|
1062
|
+
if (rules.links === "error") {
|
|
1063
|
+
const plural = broken.length === 1 ? "" : "s";
|
|
1064
|
+
const list = lines.map((line) => ` - ${line}`).join("\n");
|
|
1065
|
+
throw new Error(`Content links: "${name}" has ${broken.length} broken link${plural}:\n${list}`);
|
|
1066
|
+
}
|
|
1067
|
+
/* A cause that is not in the links themselves (a template naming a field nobody has leaves every
|
|
1068
|
+
entry unrouted) turns into a line per link, which buries the cause. Past a screenful the rest is
|
|
1069
|
+
counted, and `links: "error"` still lists every one. */
|
|
1070
|
+
for (const line of lines.slice(0, LINK_WARNINGS_LISTED)) {
|
|
1071
|
+
console.warn(`Content links: "${name}": ${line}; it renders as plain text.`);
|
|
1072
|
+
}
|
|
1073
|
+
if (lines.length > LINK_WARNINGS_LISTED) {
|
|
1074
|
+
console.warn(
|
|
1075
|
+
`Content links: "${name}": and ${lines.length - LINK_WARNINGS_LISTED} more broken links ` +
|
|
1076
|
+
`(set "links": "error" to fail the build with every one listed).`,
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** How many broken links a `links: "warn"` build names before it counts the rest. */
|
|
1082
|
+
const LINK_WARNINGS_LISTED = 25;
|
|
1083
|
+
|
|
1084
|
+
// ─── Content Type Loading ────────────────────────────────────────────────────
|
|
1085
|
+
|
|
1086
|
+
/** Per-load context a caller threads into {@link loadContentType}. */
|
|
1087
|
+
interface LoadOptions {
|
|
1088
|
+
/** The type's asset mount; content-relative references remap onto it. */
|
|
1089
|
+
mount?: AssetMount | undefined;
|
|
1090
|
+
/** The project config: `build.trailingSlash` and the i18n routing rules. */
|
|
1091
|
+
projectConfig?: ProjectConfig | undefined;
|
|
1092
|
+
/** The locale this load is for, when the source carried `{locale}`. */
|
|
1093
|
+
locale?: string | undefined;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
484
1096
|
/**
|
|
485
1097
|
* Load a single content type by its definition, dispatching through the format registry.
|
|
486
1098
|
*
|
|
@@ -488,7 +1100,7 @@ export function getContentTypeElements(
|
|
|
488
1100
|
* @param {ContentTypeDef} contentTypeDef - Content type definition from the `content` section
|
|
489
1101
|
* @param {string} projectRoot - Absolute path to project root directory
|
|
490
1102
|
* @param {FormatRegistry} registry - Format-dispatch view of the extension registry
|
|
491
|
-
* @param {
|
|
1103
|
+
* @param {LoadOptions} [options] - Asset mount, project config and locale for this load
|
|
492
1104
|
* @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
|
|
493
1105
|
*/
|
|
494
1106
|
async function loadContentType(
|
|
@@ -496,13 +1108,27 @@ async function loadContentType(
|
|
|
496
1108
|
contentTypeDef: ContentTypeDef,
|
|
497
1109
|
projectRoot: string,
|
|
498
1110
|
registry: FormatRegistry,
|
|
499
|
-
|
|
1111
|
+
options: LoadOptions = {},
|
|
500
1112
|
) {
|
|
501
1113
|
const { source } = contentTypeDef;
|
|
502
1114
|
if (!source) {
|
|
503
1115
|
return [];
|
|
504
1116
|
}
|
|
1117
|
+
const { mount, projectConfig, locale } = options;
|
|
505
1118
|
const schema = contentTypeDef.schema as ContentTypeSchema | undefined;
|
|
1119
|
+
const rules = compileTypeRules(name, contentTypeDef);
|
|
1120
|
+
const finishContext = (extra: Partial<FinishContext>): FinishContext => ({
|
|
1121
|
+
coerceDates: false,
|
|
1122
|
+
entryExtensions: new Set<string>(),
|
|
1123
|
+
locale,
|
|
1124
|
+
mount,
|
|
1125
|
+
name,
|
|
1126
|
+
projectConfig,
|
|
1127
|
+
rules,
|
|
1128
|
+
schema,
|
|
1129
|
+
sourceRoot: undefined,
|
|
1130
|
+
...extra,
|
|
1131
|
+
});
|
|
506
1132
|
|
|
507
1133
|
// Derive directive allowedNames from content type $elements (tag names from npm packages)
|
|
508
1134
|
const directiveOptions = contentTypeDef.$elements?.length
|
|
@@ -542,39 +1168,46 @@ async function loadContentType(
|
|
|
542
1168
|
`(its format block lacks "remote": true).`,
|
|
543
1169
|
);
|
|
544
1170
|
}
|
|
1171
|
+
let remote: ContentLoaderEntry[];
|
|
545
1172
|
try {
|
|
546
|
-
|
|
1173
|
+
remote = (await entry.call("load", source, {
|
|
547
1174
|
directiveOptions,
|
|
548
1175
|
schema,
|
|
1176
|
+
...(rules.idField !== undefined && { idField: rules.idField }),
|
|
549
1177
|
})) as ContentLoaderEntry[];
|
|
550
|
-
if (schema) {
|
|
551
|
-
validateEntries(entries, schema, name);
|
|
552
|
-
}
|
|
553
|
-
return entries;
|
|
554
1178
|
} catch (error) {
|
|
555
1179
|
console.warn(`Content type "${name}": ${errorMessage(error)}`);
|
|
556
1180
|
return [];
|
|
557
1181
|
}
|
|
1182
|
+
return finishEntries([{ entries: remote, filePath: source, rel: null }], finishContext({}));
|
|
558
1183
|
}
|
|
559
1184
|
|
|
560
1185
|
const resolvedSource = resolve(projectRoot, source).split("\\").join("/");
|
|
561
1186
|
const ext = extname(source).toLowerCase();
|
|
1187
|
+
// Directory sources pass their resolved root so formats can derive path-based ids for nested files
|
|
1188
|
+
const sourceRoot = extname(source) ? undefined : resolvedSource;
|
|
1189
|
+
/** Whether a discovered file is left out by `exclude` (a format class may not honor the option). */
|
|
1190
|
+
const isExcluded = (file: string) => {
|
|
1191
|
+
const rel = sourceRoot === undefined ? null : relativeTo(sourceRoot, file);
|
|
1192
|
+
return rel !== null && rules.exclude.excludedBy(rel) !== undefined;
|
|
1193
|
+
};
|
|
1194
|
+
const relOf = (file: string) => (sourceRoot === undefined ? null : relativeTo(sourceRoot, file));
|
|
562
1195
|
|
|
563
1196
|
// JSON — the native built-in
|
|
564
1197
|
if (formatName === "json" || (!entry && ext === ".json")) {
|
|
565
|
-
const
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
1198
|
+
const loaded: LoadedFile[] = [];
|
|
1199
|
+
for (const filePath of discoverJSONFiles(resolvedSource, rules.exclude)) {
|
|
1200
|
+
const rel = relOf(filePath);
|
|
1201
|
+
try {
|
|
1202
|
+
loaded.push({ entries: loadJSONEntries(filePath), filePath, rel });
|
|
1203
|
+
} catch (error) {
|
|
1204
|
+
throw unreadableFile(name, rel ?? filePath, error);
|
|
571
1205
|
}
|
|
572
|
-
entries.push(...fileEntries);
|
|
573
1206
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
1207
|
+
return finishEntries(
|
|
1208
|
+
loaded,
|
|
1209
|
+
finishContext({ entryExtensions: new Set([".json"]), sourceRoot }),
|
|
1210
|
+
);
|
|
578
1211
|
}
|
|
579
1212
|
|
|
580
1213
|
// Derive the format from the source extension when no explicit format is named
|
|
@@ -589,25 +1222,43 @@ async function loadContentType(
|
|
|
589
1222
|
}
|
|
590
1223
|
|
|
591
1224
|
// Discover entry files via the class, then load each
|
|
592
|
-
const
|
|
1225
|
+
const discovered = entry.capabilities.discover
|
|
593
1226
|
? ((await entry.call("discover", source, {
|
|
594
1227
|
baseDir: projectRoot,
|
|
1228
|
+
...(rules.exclude.empty ? {} : { exclude: contentTypeDef.exclude }),
|
|
595
1229
|
})) as string[])
|
|
596
1230
|
: [resolvedSource];
|
|
1231
|
+
const files = discovered.filter((file) => !isExcluded(file));
|
|
597
1232
|
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
const entries: ContentLoaderEntry[] = [];
|
|
1233
|
+
const fileExtensions = files.map((file) => extname(file).toLowerCase());
|
|
1234
|
+
const loaded: LoadedFile[] = [];
|
|
601
1235
|
for (const filePath of files) {
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
1236
|
+
const rel = relOf(filePath);
|
|
1237
|
+
let fileEntries: ContentLoaderEntry[];
|
|
1238
|
+
try {
|
|
1239
|
+
fileEntries = (await entry.call("load", filePath, {
|
|
1240
|
+
directiveOptions,
|
|
1241
|
+
schema,
|
|
1242
|
+
...(sourceRoot !== undefined && { sourceRoot }),
|
|
1243
|
+
...(rules.alerts !== undefined && { alerts: rules.alerts }),
|
|
1244
|
+
...(rules.idField !== undefined && { idField: rules.idField }),
|
|
1245
|
+
onUnknownAlert: (type: string) =>
|
|
1246
|
+
console.warn(
|
|
1247
|
+
`Content callouts: "${name}": "${rel ?? filePath}" has a [!${type}] callout, but ` +
|
|
1248
|
+
`"${type.toLowerCase()}" is not an enabled type, so it stays a blockquote with the ` +
|
|
1249
|
+
`marker showing. Add "alerts": { "${type.toUpperCase()}": true } to render it.`,
|
|
1250
|
+
),
|
|
1251
|
+
})) as ContentLoaderEntry[];
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
throw unreadableFile(name, rel ?? filePath, error);
|
|
609
1254
|
}
|
|
610
|
-
|
|
1255
|
+
loaded.push({ entries: fileEntries, filePath, rel });
|
|
1256
|
+
}
|
|
1257
|
+
if (loaded.length === 0 && !rules.exclude.empty) {
|
|
1258
|
+
console.warn(
|
|
1259
|
+
`Content type "${name}": no file was loaded from "${source}" with "exclude" set; if the ` +
|
|
1260
|
+
`folder is not empty, an exclude pattern is hiding all of it.`,
|
|
1261
|
+
);
|
|
611
1262
|
}
|
|
612
1263
|
|
|
613
1264
|
/*
|
|
@@ -615,14 +1266,26 @@ async function loadContentType(
|
|
|
615
1266
|
* both the entries and the schema: `Csv.load` receives a schema and `Markdown.load` does not, so
|
|
616
1267
|
* doing it per-format would mean doing it twice and missing every third-party format class.
|
|
617
1268
|
*/
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
1269
|
+
return finishEntries(
|
|
1270
|
+
loaded,
|
|
1271
|
+
finishContext({
|
|
1272
|
+
coerceDates: true,
|
|
1273
|
+
entryExtensions: new Set([...entry.extensions, ...fileExtensions]),
|
|
1274
|
+
sourceRoot,
|
|
1275
|
+
}),
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
624
1278
|
|
|
625
|
-
|
|
1279
|
+
/**
|
|
1280
|
+
* The error for one file that would not load, with its path: a parse failure names a line and a
|
|
1281
|
+
* reason but never the file, and a vault is hundreds of them.
|
|
1282
|
+
*/
|
|
1283
|
+
function unreadableFile(type: string, path: string, error: unknown): Error {
|
|
1284
|
+
return new Error(
|
|
1285
|
+
`Content type "${type}": cannot read "${path}": ${errorMessage(error)}. Fix the file, or add it ` +
|
|
1286
|
+
`to "exclude" if it is not a document (a templates folder, for example).`,
|
|
1287
|
+
{ cause: error },
|
|
1288
|
+
);
|
|
626
1289
|
}
|
|
627
1290
|
|
|
628
1291
|
/** Error message for an unregistered non-JSON extension, naming the fix. */
|
|
@@ -782,6 +1445,102 @@ export function resolveContentTypeRefs(
|
|
|
782
1445
|
}
|
|
783
1446
|
}
|
|
784
1447
|
|
|
1448
|
+
/** The page patterns of a project with their locale prefix taken off (`/fr/kb/*` → `/kb/*`). */
|
|
1449
|
+
function withoutLocalePrefix(urlPattern: string, prefix: string): string {
|
|
1450
|
+
return prefix !== "" && (urlPattern === prefix || urlPattern.startsWith(`${prefix}/`))
|
|
1451
|
+
? urlPattern.slice(prefix.length) || "/"
|
|
1452
|
+
: urlPattern;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/** Content types whose unserved routes were already reported, per loaded section (once per build). */
|
|
1456
|
+
const REPORTED_UNSERVED = new WeakMap<object, Set<string>>();
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* Report the routed entries that no dynamic page of the project produces.
|
|
1460
|
+
*
|
|
1461
|
+
* Each page expands the routes its own pattern fits, so a project with a `[category]/[slug]` page
|
|
1462
|
+
* and a `[category]` page serves different entries from each, and one page skipping an entry is
|
|
1463
|
+
* normal. An entry NO page serves is different: it has a route, links to it were rewritten to that
|
|
1464
|
+
* route, and nothing generates it. It is reported once per content type, with a few of the routes.
|
|
1465
|
+
*/
|
|
1466
|
+
function reportUnservedRoutes(
|
|
1467
|
+
contentType: string,
|
|
1468
|
+
entries: ContentLoaderEntry[],
|
|
1469
|
+
ctx: ResolvePathsContext,
|
|
1470
|
+
prefix: string,
|
|
1471
|
+
): void {
|
|
1472
|
+
if (ctx.patterns === undefined) {
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
const reported = REPORTED_UNSERVED.get(ctx.data) ?? new Set<string>();
|
|
1476
|
+
if (reported.has(contentType)) {
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const patterns = ctx.patterns.map((pattern) => withoutLocalePrefix(pattern, prefix));
|
|
1480
|
+
const unserved = entries
|
|
1481
|
+
.map((entry) => entry._meta?.route)
|
|
1482
|
+
.filter(
|
|
1483
|
+
(route): route is string =>
|
|
1484
|
+
route !== undefined &&
|
|
1485
|
+
!patterns.some((pattern) => routeParams(route, pattern, []) !== null),
|
|
1486
|
+
);
|
|
1487
|
+
if (unserved.length === 0) {
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
reported.add(contentType);
|
|
1491
|
+
REPORTED_UNSERVED.set(ctx.data, reported);
|
|
1492
|
+
const shown = unserved.slice(0, 3).join(", ");
|
|
1493
|
+
console.warn(
|
|
1494
|
+
`Warning: $paths for content type "${contentType}": ${unserved.length} routed ` +
|
|
1495
|
+
`${unserved.length === 1 ? "entry has" : "entries have"} a route that no dynamic page ` +
|
|
1496
|
+
`produces (${shown}${unserved.length > 3 ? ", ..." : ""}), so ` +
|
|
1497
|
+
`${unserved.length === 1 ? "it is" : "they are"} not generated and links to ` +
|
|
1498
|
+
`${unserved.length === 1 ? "it" : "them"} lead nowhere. Add a page whose URL pattern fits ` +
|
|
1499
|
+
`them, or change the "route" template.`,
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* `$paths` for a content type with a `route`: one parameter map per entry the page can serve.
|
|
1505
|
+
*
|
|
1506
|
+
* The page pattern arrives with the locale prefix still on it (`/fr/kb/*`) while an entry's route
|
|
1507
|
+
* is locale-free, so the prefix is taken off first. An entry the pattern cannot produce is skipped,
|
|
1508
|
+
* and a page that serves none of them is reported once with an example route, because the usual
|
|
1509
|
+
* cause is a template and a page that were never meant for each other. Entries that NO page of the
|
|
1510
|
+
* project serves are reported too (see {@link reportUnservedRoutes}).
|
|
1511
|
+
*/
|
|
1512
|
+
function routedPaths(
|
|
1513
|
+
contentType: string,
|
|
1514
|
+
entries: ContentLoaderEntry[],
|
|
1515
|
+
ctx: ResolvePathsContext,
|
|
1516
|
+
urlPattern: string,
|
|
1517
|
+
): Record<string, unknown>[] {
|
|
1518
|
+
const { i18n } = resolveI18n(ctx.projectConfig ?? {});
|
|
1519
|
+
const prefix = localeUrlPrefix(ctx.locale, i18n);
|
|
1520
|
+
const pattern = withoutLocalePrefix(urlPattern, prefix);
|
|
1521
|
+
const paths: Record<string, unknown>[] = [];
|
|
1522
|
+
let example: string | undefined;
|
|
1523
|
+
for (const entry of entries) {
|
|
1524
|
+
const route = entry._meta?.route;
|
|
1525
|
+
if (route === undefined) {
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
example ??= route;
|
|
1529
|
+
const params = routeParams(route, pattern, ctx.params ?? []);
|
|
1530
|
+
if (params) {
|
|
1531
|
+
paths.push({ ...params, ...(entry._meta === undefined ? {} : { _meta: entry._meta }) });
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
if (paths.length === 0 && example !== undefined) {
|
|
1535
|
+
console.warn(
|
|
1536
|
+
`Warning: $paths for content type "${contentType}": no entry route fits the page pattern ` +
|
|
1537
|
+
`"${urlPattern}" (for example ${example}), so this page generates nothing`,
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
reportUnservedRoutes(contentType, entries, ctx, prefix);
|
|
1541
|
+
return paths;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
785
1544
|
// ─── The Content project-section class ───────────────────────────────────────
|
|
786
1545
|
|
|
787
1546
|
/**
|
|
@@ -831,8 +1590,18 @@ export class Content {
|
|
|
831
1590
|
}
|
|
832
1591
|
|
|
833
1592
|
/**
|
|
834
|
-
* Expand a content-type `$paths` source into route-param maps
|
|
835
|
-
*
|
|
1593
|
+
* Expand a content-type `$paths` source into route-param maps.
|
|
1594
|
+
*
|
|
1595
|
+
* **With `param` or `field`** (or on a type with no `route`): one `{ [param]: value }` per entry,
|
|
1596
|
+
* `param` defaulting to "slug" and `field` to "id" (the entry id). Unchanged since before routes
|
|
1597
|
+
* existed, and still the right shape for a flat `[slug]` collection.
|
|
1598
|
+
*
|
|
1599
|
+
* **With neither, on a type that declares a `route`:** the page's own URL pattern decides. Each
|
|
1600
|
+
* entry's route is matched against the pattern the host passes (`/kb/:category/:slug`, `/kb/*`)
|
|
1601
|
+
* and the parameters that pattern needs are derived from it, so the same `$paths: {
|
|
1602
|
+
* "contentType": "kb" }` serves `[category]/[slug].json`, `[...path].json` or a `[category].json`
|
|
1603
|
+
* section page, and the page generated for an entry is always at the URL its links point to. An
|
|
1604
|
+
* entry whose route the pattern cannot produce belongs to another page and is skipped here.
|
|
836
1605
|
*
|
|
837
1606
|
* Each map also carries the entry's own `_meta`, under that reserved name. `_meta` is not a route
|
|
838
1607
|
* parameter and the host strips it before substitution — it is how a fact about the _entry_
|
|
@@ -841,7 +1610,7 @@ export class Content {
|
|
|
841
1610
|
* timestamp in `<lastmod>`, so the whole archive looks edited whenever the template is.
|
|
842
1611
|
*
|
|
843
1612
|
* @param {ContentPathsSource} pathsDef - The `$paths` value ({ contentType, param?, field? })
|
|
844
|
-
* @param {ResolvePathsContext} ctx - Host context ({ data, projectConfig, root })
|
|
1613
|
+
* @param {ResolvePathsContext} ctx - Host context ({ data, projectConfig, root, urlPattern })
|
|
845
1614
|
* @returns {Promise<Record<string, unknown>[]>} Array of route-param objects
|
|
846
1615
|
*/
|
|
847
1616
|
static async resolvePaths(
|
|
@@ -869,12 +1638,31 @@ export class Content {
|
|
|
869
1638
|
localized && typeof wanted === "string"
|
|
870
1639
|
? entries.filter((entry) => entry._meta?.locale === wanted)
|
|
871
1640
|
: entries;
|
|
1641
|
+
if (
|
|
1642
|
+
pathsDef.param === undefined &&
|
|
1643
|
+
pathsDef.field === undefined &&
|
|
1644
|
+
ctx.urlPattern !== undefined &&
|
|
1645
|
+
scoped.some((entry) => entry._meta?.route !== undefined)
|
|
1646
|
+
) {
|
|
1647
|
+
return routedPaths(pathsDef.contentType, scoped, ctx, ctx.urlPattern);
|
|
1648
|
+
}
|
|
872
1649
|
const paths: Record<string, unknown>[] = [];
|
|
873
1650
|
for (const entry of scoped) {
|
|
874
1651
|
const value = field === "id" ? entry.id : (entry.data[field] ?? entry.id);
|
|
875
1652
|
if (!value) {
|
|
876
1653
|
continue;
|
|
877
1654
|
}
|
|
1655
|
+
/* The value becomes a directory under the output. It may be frontmatter (`field`, or an id
|
|
1656
|
+
taken from `idField`), so a value that would climb out of the output directory does not
|
|
1657
|
+
get a page. */
|
|
1658
|
+
const unsafe = traversalReason(String(value));
|
|
1659
|
+
if (unsafe !== null) {
|
|
1660
|
+
console.warn(
|
|
1661
|
+
`Warning: $paths for content type "${pathsDef.contentType}": the ${field === "id" ? "id" : `"${field}"`} ` +
|
|
1662
|
+
`of "${entry.id}" ${unsafe}, so no page is generated for it`,
|
|
1663
|
+
);
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
878
1666
|
const pathEntry: Record<string, unknown> = { [param]: value };
|
|
879
1667
|
if (entry._meta !== undefined) {
|
|
880
1668
|
pathEntry._meta = entry._meta;
|