@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.
@@ -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
+ }