@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
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content routes — the one place that says what URL a content entry has.
|
|
3
|
+
*
|
|
4
|
+
* A content type's `route` is a template over the entry's own facts, for example
|
|
5
|
+
* `/kb/{category:slug}/{slug}/`. The loader renders it once per entry and stamps the result at
|
|
6
|
+
* `_meta.route`; from then on **everything that needs a URL reads that stamp**: link rewriting
|
|
7
|
+
* (`content-links.ts`), `$paths` expansion (`Content.resolvePaths`), `ContentEntry`'s binding to
|
|
8
|
+
* its page, the search index and the feed. A route that was recomputed in each of those places
|
|
9
|
+
* would be a route that could disagree in one of them, and the symptom of that — a page that exists
|
|
10
|
+
* and a link to it that 404s — is exactly what this module exists to rule out.
|
|
11
|
+
*
|
|
12
|
+
* Pure string logic with no `node:` imports.
|
|
13
|
+
*
|
|
14
|
+
* @module @jxsuite/parser/content-routes
|
|
15
|
+
* @license MIT
|
|
16
|
+
* @docs framework/site/content-collections
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readField } from "./content-rules.ts";
|
|
20
|
+
|
|
21
|
+
// ─── slugs ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Turn text into one URL path segment: Latin diacritics folded away, `&` read as "and", apostrophes
|
|
25
|
+
* dropped, everything that is not a letter or digit collapsed to single hyphens.
|
|
26
|
+
*
|
|
27
|
+
* Letters and digits of every script are kept, so a Japanese or Cyrillic title still yields a slug
|
|
28
|
+
* made of its own words. Diacritics are folded only on a Latin base letter, because in Devanagari
|
|
29
|
+
* or Thai the same Unicode category carries vowels, and folding those would spell a different word.
|
|
30
|
+
* `&` becoming `and` is deliberate: `Git & Dev Tools` should read `git-and-dev-tools`, which is
|
|
31
|
+
* what a person writes by hand, instead of the `git-dev-tools` a plain strip would produce.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} text
|
|
34
|
+
* @returns {string} The slug, or `""` when the text holds no letter or digit
|
|
35
|
+
*/
|
|
36
|
+
export function slugifySegment(text: string): string {
|
|
37
|
+
return text
|
|
38
|
+
.normalize("NFKD")
|
|
39
|
+
.replaceAll(/(?<=\p{Script=Latin})\p{M}+/gu, "")
|
|
40
|
+
.normalize("NFC")
|
|
41
|
+
.replaceAll("&", " and ")
|
|
42
|
+
.replaceAll(/['’]/g, "")
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replaceAll(/[^\p{L}\p{M}\p{N}]+/gu, "-")
|
|
45
|
+
.replaceAll(/^-+|-+$/g, "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** {@link slugifySegment} applied to each `/`-separated part, so a path-shaped id keeps its depth. */
|
|
49
|
+
export function slugifyPath(text: string): string {
|
|
50
|
+
return text
|
|
51
|
+
.split("/")
|
|
52
|
+
.map((part) => slugifySegment(part))
|
|
53
|
+
.filter((part) => part !== "")
|
|
54
|
+
.join("/");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── templates ──────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
const TRANSFORMS: Record<string, (value: string) => string> = {
|
|
60
|
+
lower: (value) => value.toLowerCase(),
|
|
61
|
+
raw: (value) => value,
|
|
62
|
+
slug: slugifyPath,
|
|
63
|
+
upper: (value) => value.toUpperCase(),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** One piece of a parsed template: literal text, or a placeholder with its transforms. */
|
|
67
|
+
type Token = { literal: string } | { name: string; transforms: string[]; source: string };
|
|
68
|
+
|
|
69
|
+
/** A parsed route template. */
|
|
70
|
+
export interface RouteTemplate {
|
|
71
|
+
source: string;
|
|
72
|
+
tokens: Token[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The `route` and `indexRoute` options as the loader uses them. */
|
|
76
|
+
export interface RouteSpec {
|
|
77
|
+
/** Used for ordinary entries, and for directory indexes when `index` is absent. */
|
|
78
|
+
entry: RouteTemplate;
|
|
79
|
+
/** Used for the README / index file of a directory. */
|
|
80
|
+
index: RouteTemplate | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse one template. Throws a readable error for anything that could not render, so a typo in
|
|
85
|
+
* `project.json` fails the build on the first entry rather than producing routes with the
|
|
86
|
+
* placeholder text in them.
|
|
87
|
+
*/
|
|
88
|
+
export function parseRouteTemplate(source: unknown, where: string): RouteTemplate {
|
|
89
|
+
if (typeof source !== "string" || !source.startsWith("/")) {
|
|
90
|
+
throw new TypeError(
|
|
91
|
+
`${where}: a route template is a site-absolute path starting with "/", got ${JSON.stringify(source)}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const tokens: Token[] = [];
|
|
95
|
+
let last = 0;
|
|
96
|
+
for (const match of source.matchAll(/\{([^{}]*)\}/g)) {
|
|
97
|
+
const index = match.index ?? 0;
|
|
98
|
+
if (index > last) {
|
|
99
|
+
tokens.push({ literal: source.slice(last, index) });
|
|
100
|
+
}
|
|
101
|
+
const [name, ...transforms] = match[1]!.split(":").map((part) => part.trim());
|
|
102
|
+
if (!name) {
|
|
103
|
+
throw new TypeError(`${where}: empty placeholder "{}" in "${source}"`);
|
|
104
|
+
}
|
|
105
|
+
for (const transform of transforms) {
|
|
106
|
+
if (!(transform in TRANSFORMS)) {
|
|
107
|
+
throw new TypeError(
|
|
108
|
+
`${where}: unknown transform "${transform}" in "{${match[1]}}" (use ${Object.keys(TRANSFORMS).join(", ")})`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
tokens.push({ name, source: match[0], transforms });
|
|
113
|
+
last = index + match[0].length;
|
|
114
|
+
}
|
|
115
|
+
if (last < source.length) {
|
|
116
|
+
tokens.push({ literal: source.slice(last) });
|
|
117
|
+
}
|
|
118
|
+
if (/[{}]/.test(tokens.map((t) => ("literal" in t ? t.literal : "")).join(""))) {
|
|
119
|
+
throw new TypeError(`${where}: unbalanced brace in "${source}"`);
|
|
120
|
+
}
|
|
121
|
+
return { source, tokens };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse the `route` option (every entry) and `indexRoute` (a directory's README / index file).
|
|
126
|
+
*
|
|
127
|
+
* `indexRoute` without a `route` is an error rather than a silent no-op: an index template with no
|
|
128
|
+
* entry template would route the READMEs and leave every other document without a URL.
|
|
129
|
+
*/
|
|
130
|
+
export function parseRouteConfig(
|
|
131
|
+
route: unknown,
|
|
132
|
+
indexRoute: unknown,
|
|
133
|
+
typeName: string,
|
|
134
|
+
): RouteSpec | null {
|
|
135
|
+
const where = `Content type "${typeName}"`;
|
|
136
|
+
if (route === undefined) {
|
|
137
|
+
if (indexRoute !== undefined) {
|
|
138
|
+
throw new TypeError(`${where}: "indexRoute" needs a "route" for every other entry`);
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
entry: parseRouteTemplate(route, `${where}: route`),
|
|
144
|
+
index: indexRoute === undefined ? null : parseRouteTemplate(indexRoute, `${where}: indexRoute`),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── rendering ──────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/** What a template can read about one entry. */
|
|
151
|
+
export interface RouteSubject {
|
|
152
|
+
/** The entry id (after `idField`). */
|
|
153
|
+
id: string;
|
|
154
|
+
/** Frontmatter / entry data. */
|
|
155
|
+
data: Record<string, unknown>;
|
|
156
|
+
/** The source file, relative to the source root, `/`-separated, with its extension. */
|
|
157
|
+
path: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `README` and `index` are the file names that stand for their directory. */
|
|
161
|
+
const INDEX_NAME = /^(?:index|readme)\.[^./]+$/i;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether a source file is the index of its directory (`README.md`, `index.md`).
|
|
165
|
+
*
|
|
166
|
+
* Matches the rule entry ids use: `studio/index.md` has always been the id `studio`, and
|
|
167
|
+
* `README.md` now is too, because that is the file a repository, GitHub and Obsidian all show for a
|
|
168
|
+
* folder.
|
|
169
|
+
*/
|
|
170
|
+
export function isDirectoryIndex(path: string): boolean {
|
|
171
|
+
return INDEX_NAME.test(path.slice(path.lastIndexOf("/") + 1));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The directory of a relative path (`""` for a file at the root). */
|
|
175
|
+
export function directoryOf(path: string): string {
|
|
176
|
+
const slash = path.lastIndexOf("/");
|
|
177
|
+
return slash === -1 ? "" : path.slice(0, slash);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A field value as route text, or null when it cannot name a path segment. */
|
|
181
|
+
function textOf(value: unknown): string | null {
|
|
182
|
+
if (typeof value === "string") {
|
|
183
|
+
return value.trim() === "" ? null : value;
|
|
184
|
+
}
|
|
185
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
186
|
+
return String(value);
|
|
187
|
+
}
|
|
188
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
|
189
|
+
return value.toISOString().slice(0, 10);
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The value of a placeholder name for one entry, or null when it has none. */
|
|
195
|
+
function resolveName(name: string, subject: RouteSubject): string | null {
|
|
196
|
+
switch (name) {
|
|
197
|
+
case "id": {
|
|
198
|
+
return textOf(subject.id);
|
|
199
|
+
}
|
|
200
|
+
case "dir": {
|
|
201
|
+
// The one placeholder that may be legitimately empty: a file at the root has no directory.
|
|
202
|
+
return directoryOf(subject.path);
|
|
203
|
+
}
|
|
204
|
+
case "file": {
|
|
205
|
+
const base = subject.path.slice(subject.path.lastIndexOf("/") + 1);
|
|
206
|
+
const dot = base.lastIndexOf(".");
|
|
207
|
+
return textOf(dot > 0 ? base.slice(0, dot) : base);
|
|
208
|
+
}
|
|
209
|
+
default: {
|
|
210
|
+
return textOf(readField(subject.data, name.startsWith("data.") ? name.slice(5) : name));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Why a path value could climb out of the output directory, or `null` when it cannot.
|
|
217
|
+
*
|
|
218
|
+
* A page is written to disk at a path under `dist/`, built from a route or from a value a `$paths`
|
|
219
|
+
* source hands to a page, and either can come from frontmatter. A segment that is `.` or `..` would
|
|
220
|
+
* climb out of the output directory, and a backslash or a NUL is a separator or a terminator on
|
|
221
|
+
* some platform. Every value that becomes a path goes through here.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} path - A `/`-separated path
|
|
224
|
+
* @returns {string | null} A reason, or null when the path stays where it is put
|
|
225
|
+
*/
|
|
226
|
+
export function traversalReason(path: string): string | null {
|
|
227
|
+
if (path.includes("\0")) {
|
|
228
|
+
return "contains a NUL character";
|
|
229
|
+
}
|
|
230
|
+
for (const segment of path.split("/")) {
|
|
231
|
+
if (segment === "." || segment === "..") {
|
|
232
|
+
return 'contains a "." or ".." segment';
|
|
233
|
+
}
|
|
234
|
+
if (segment.includes("\\")) {
|
|
235
|
+
return "contains a backslash";
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Why a path cannot be a route, or `null` when it can: {@link traversalReason}, plus an unencoded
|
|
243
|
+
* `?`, `#` or `%`, which everything downstream (the sitemap writer among them) would read as a
|
|
244
|
+
* query, a fragment or an escape rather than as part of a name.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} path - A `/`-separated path
|
|
247
|
+
* @returns {string | null}
|
|
248
|
+
*/
|
|
249
|
+
export function unsafePathReason(path: string): string | null {
|
|
250
|
+
const traversal = traversalReason(path);
|
|
251
|
+
if (traversal !== null) {
|
|
252
|
+
return traversal;
|
|
253
|
+
}
|
|
254
|
+
return /[?#%]/.test(path)
|
|
255
|
+
? 'contains a "?", "#" or "%", which a URL path cannot carry unescaped'
|
|
256
|
+
: null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Normalize a rendered route to the one form it is stored in: a leading `/`, no doubled or trailing
|
|
261
|
+
* slash. The trailing slash a template ends with is style; whether the built URL carries one is the
|
|
262
|
+
* site's `build.trailingSlash` setting, applied where the URL is written.
|
|
263
|
+
*
|
|
264
|
+
* Returns null when {@link unsafePathReason} finds a reason the path cannot be a route.
|
|
265
|
+
*/
|
|
266
|
+
export function normalizeRoute(path: string): string | null {
|
|
267
|
+
const segments = path.split("/").filter((segment) => segment !== "");
|
|
268
|
+
if (unsafePathReason(segments.join("/")) !== null) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
return `/${segments.join("/")}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The result of rendering a route: the path, or why there is none. */
|
|
275
|
+
export type RenderedRoute = { route: string } | { error: string };
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Render a content type's route for one entry.
|
|
279
|
+
*
|
|
280
|
+
* A directory index uses the `index` template when there is one; everything else uses `entry`.
|
|
281
|
+
* Placeholders resolve to `id`, `dir` (the file's directory), `file` (its name without extension)
|
|
282
|
+
* or a frontmatter field (`data.` optional prefix, dotted paths allowed), each passed through its
|
|
283
|
+
* transforms in order. A missing or non-text field is an `error` naming the field, not a route with
|
|
284
|
+
* a hole in it.
|
|
285
|
+
*/
|
|
286
|
+
export function renderRoute(spec: RouteSpec, subject: RouteSubject): RenderedRoute {
|
|
287
|
+
const template = isDirectoryIndex(subject.path) && spec.index ? spec.index : spec.entry;
|
|
288
|
+
let out = "";
|
|
289
|
+
for (const token of template.tokens) {
|
|
290
|
+
if ("literal" in token) {
|
|
291
|
+
out += token.literal;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
let value = resolveName(token.name, subject);
|
|
295
|
+
for (const transform of token.transforms) {
|
|
296
|
+
value = value === null ? null : TRANSFORMS[transform]!(value);
|
|
297
|
+
}
|
|
298
|
+
/* `dir` may legitimately be empty (a file at the root); every other value must name something,
|
|
299
|
+
or two entries would silently share the route with the placeholder blanked out. */
|
|
300
|
+
if (value === null || (value === "" && token.name !== "dir")) {
|
|
301
|
+
return {
|
|
302
|
+
error: `field "${token.name}" (needed by "${token.source}" in ${template.source}) is missing, empty or is not text`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
out += value;
|
|
306
|
+
}
|
|
307
|
+
const route = normalizeRoute(out);
|
|
308
|
+
if (route === null) {
|
|
309
|
+
return { error: `the route "${out}" ${unsafePathReason(out) ?? "is not a path"}` };
|
|
310
|
+
}
|
|
311
|
+
return { route };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─── matching a route against a page pattern ────────────────────────────────
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The route parameters a page pattern needs to produce `route`, or null when it cannot.
|
|
318
|
+
*
|
|
319
|
+
* `pattern` is a file-based route pattern (`/kb/:category/:slug`, `/kb/*`) and `names` its
|
|
320
|
+
* parameter names in order. A static segment must match exactly, a `:name` takes one segment, and a
|
|
321
|
+
* trailing `*` takes one or more and is reported under the page's last parameter name, which is
|
|
322
|
+
* what `[...path].json` called it. This is what lets one `$paths: { contentType }` serve whatever
|
|
323
|
+
* page shape the author chose, with no `param` to keep in step with the template.
|
|
324
|
+
*/
|
|
325
|
+
export function routeParams(
|
|
326
|
+
route: string,
|
|
327
|
+
pattern: string,
|
|
328
|
+
names: readonly string[],
|
|
329
|
+
): Record<string, string> | null {
|
|
330
|
+
const want = route.split("/").filter((segment) => segment !== "");
|
|
331
|
+
const have = pattern.split("/").filter((segment) => segment !== "");
|
|
332
|
+
const params: Record<string, string> = {};
|
|
333
|
+
for (const [i, segment] of have.entries()) {
|
|
334
|
+
if (segment === "*") {
|
|
335
|
+
const rest = want.slice(i);
|
|
336
|
+
if (i !== have.length - 1 || rest.length === 0) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
params[names.at(-1) ?? "path"] = rest.join("/");
|
|
340
|
+
return params;
|
|
341
|
+
}
|
|
342
|
+
if (i >= want.length) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
if (segment.startsWith(":")) {
|
|
346
|
+
params[segment.slice(1)] = want[i]!;
|
|
347
|
+
} else if (segment !== want[i]) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return want.length === have.length ? params : null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The route as an `href` path: each segment percent-encoded, so a route that carries a space or a
|
|
356
|
+
* non-ASCII letter is still a valid URL, joined with the site's trailing-slash setting.
|
|
357
|
+
*/
|
|
358
|
+
export function routeHref(route: string, trailingSlash: string, localePrefix = ""): string {
|
|
359
|
+
const path = route
|
|
360
|
+
.split("/")
|
|
361
|
+
.filter((segment) => segment !== "")
|
|
362
|
+
.map((segment) => encodeURIComponent(segment))
|
|
363
|
+
.join("/");
|
|
364
|
+
const base = path === "" ? localePrefix : `${localePrefix}/${path}`;
|
|
365
|
+
if (base === "") {
|
|
366
|
+
return "/";
|
|
367
|
+
}
|
|
368
|
+
return trailingSlash === "never" ? base : `${base}/`;
|
|
369
|
+
}
|