@jxsuite/parser 1.8.2 → 2.0.1

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,474 @@
1
+ /**
2
+ * Content rules — the two declarative filters a content type can carry: `exclude` (which files are
3
+ * never read) and `where` (which entries are kept once their frontmatter is known).
4
+ *
5
+ * Both are **data, never code**. A `project.json` is read by Studio, by the dev server and by CI,
6
+ * so a filter that needed `eval` or a function would make every one of those a place that executes
7
+ * the project's text. `exclude` is a list of globs and `where` is a small JSON query, and the
8
+ * grammar of each is closed: an unknown operator is a build error naming the key, never a silent
9
+ * no-match.
10
+ *
11
+ * Pure string and object logic with no `node:` imports, so the same rules answer the loader, a
12
+ * format class's `discover`, the link resolver and any host that wants to preview them.
13
+ *
14
+ * @module @jxsuite/parser/content-rules
15
+ * @license MIT
16
+ * @docs framework/site/content-collections
17
+ */
18
+
19
+ // ─── exclude: globs relative to the source root ─────────────────────────────
20
+
21
+ /**
22
+ * What an `exclude` list says about a path.
23
+ *
24
+ * `excludedBy` answers "is this FILE excluded, and by which pattern" so a build message can name
25
+ * the rule that hid a document. `excludesDir` answers "is everything below this DIRECTORY
26
+ * excluded", which is what lets a walk skip `node_modules` or `.obsidian` without listing a single
27
+ * file in them.
28
+ */
29
+ export interface ExcludeMatcher {
30
+ /** The pattern that excludes `path` (relative to the source root, `/`-separated), if any. */
31
+ excludedBy: (path: string) => string | undefined;
32
+ /** Whether a pattern excludes every file under `dir`, so a walker can prune it. */
33
+ excludesDir: (dir: string) => boolean;
34
+ /** True when there are no patterns, so a caller can skip the work. */
35
+ readonly empty: boolean;
36
+ }
37
+
38
+ /** The answer of a rule that matched nothing. */
39
+ const nothing = (): undefined => {};
40
+
41
+ /** Characters that are syntax in a regular expression and mean nothing special in a glob. */
42
+ const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/g;
43
+
44
+ function escapeRegExp(text: string): string {
45
+ return text.replaceAll(REGEXP_SPECIAL, String.raw`\$&`);
46
+ }
47
+
48
+ /**
49
+ * Expand `{a,b}` alternatives into one pattern per choice (`a/{x,y}.md` → `a/x.md`, `a/y.md`).
50
+ *
51
+ * Nested groups expand recursively and a group with no top-level comma stays literal, as in a
52
+ * shell, so `{}` and `{a}` are not accidentally deleted. A backslash escapes a brace.
53
+ */
54
+ export function expandBraces(pattern: string): string[] {
55
+ let depth = 0;
56
+ let open = -1;
57
+ const commas: number[] = [];
58
+ for (let i = 0; i < pattern.length; i++) {
59
+ const ch = pattern[i];
60
+ if (ch === "\\") {
61
+ i += 1;
62
+ } else if (ch === "{") {
63
+ if (depth === 0) {
64
+ open = i;
65
+ commas.length = 0;
66
+ }
67
+ depth += 1;
68
+ } else if (ch === "," && depth === 1) {
69
+ commas.push(i);
70
+ } else if (ch === "}" && depth > 0) {
71
+ depth -= 1;
72
+ if (depth === 0 && commas.length > 0) {
73
+ const head = pattern.slice(0, open);
74
+ const tail = pattern.slice(i + 1);
75
+ const bounds = [open, ...commas, i];
76
+ const out: string[] = [];
77
+ for (let b = 0; b < bounds.length - 1; b++) {
78
+ const choice = pattern.slice(bounds[b]! + 1, bounds[b + 1]!);
79
+ out.push(...expandBraces(`${head}${choice}${tail}`));
80
+ }
81
+ return out;
82
+ }
83
+ }
84
+ }
85
+ return [pattern];
86
+ }
87
+
88
+ /**
89
+ * The most `*` runs one path segment may carry. A regular expression has no possessive quantifier,
90
+ * so each further run multiplies the work a non-matching name costs (`*a*a*a*a*a*a*a*a*b` against
91
+ * sixty letters took twenty seconds). No real pattern needs more than a couple, and a limit that
92
+ * names the pattern is kinder than a build that appears to hang.
93
+ */
94
+ const MAX_STAR_RUNS = 4;
95
+
96
+ /** One path segment of a glob as a regular-expression source (`*`, `?`, `[...]`, escapes). */
97
+ function segmentToRegExp(segment: string): string {
98
+ let out = "";
99
+ let stars = 0;
100
+ for (let i = 0; i < segment.length; i++) {
101
+ const ch = segment[i]!;
102
+ if (ch === "\\" && i + 1 < segment.length) {
103
+ i += 1;
104
+ out += escapeRegExp(segment[i]!);
105
+ } else if (ch === "*") {
106
+ while (segment[i + 1] === "*") {
107
+ i += 1;
108
+ }
109
+ stars += 1;
110
+ if (stars > MAX_STAR_RUNS) {
111
+ throw new TypeError(`more than ${MAX_STAR_RUNS} "*" wildcards in one path segment`);
112
+ }
113
+ out += "[^/]*";
114
+ } else if (ch === "?") {
115
+ out += "[^/]";
116
+ } else if (ch === "[") {
117
+ const close = segment.indexOf("]", i + 2);
118
+ let body = close === -1 ? "" : segment.slice(i + 1, close);
119
+ let negate = false;
120
+ if (body.startsWith("!") || body.startsWith("^")) {
121
+ negate = true;
122
+ body = body.slice(1);
123
+ }
124
+ if (close === -1 || body === "") {
125
+ // No closing bracket, or nothing inside one: the bracket is an ordinary character.
126
+ out += String.raw`\[`;
127
+ } else {
128
+ // A negated class must not match "/", or `a[!x]b` would reach across directories.
129
+ out += `[${negate ? "^/" : ""}${body.replaceAll("\\", String.raw`\\`)}]`;
130
+ i = close;
131
+ }
132
+ } else {
133
+ out += escapeRegExp(ch);
134
+ }
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /** Strip the decorations that do not change what a pattern means. */
140
+ function cleanPattern(pattern: string): string {
141
+ let out = pattern.replace(/^\.\//, "").replace(/^\/+/, "");
142
+ // A trailing slash names a directory, so it means "everything below it".
143
+ if (out.endsWith("/")) {
144
+ out += "**";
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * Compile a glob (already brace-expanded) to an anchored regular expression over a relative path.
151
+ *
152
+ * Semantics, chosen to be predictable rather than shell-like: `*` and `?` stop at `/`, `**` as a
153
+ * whole segment crosses directories, and **dotfiles are ordinary files**. A pattern that should
154
+ * skip `.obsidian/` says so (the pattern `.*` followed by `/**`) instead of relying on a hidden
155
+ * default, because the same glob must behave identically in a vault, a checkout and CI.
156
+ */
157
+ export function globToRegExp(pattern: string): RegExp {
158
+ // Adjacent `**` segments mean what one does, and each extra one would multiply the matching work.
159
+ const segments = cleanPattern(pattern)
160
+ .split("/")
161
+ .filter((segment, i, all) => !(segment === "**" && all[i - 1] === "**"));
162
+ let source = "";
163
+ try {
164
+ for (const [i, segment] of segments.entries()) {
165
+ const last = i === segments.length - 1;
166
+ if (segment === "**") {
167
+ source += last ? ".+" : "(?:[^/]+/)*";
168
+ } else {
169
+ source += segmentToRegExp(segment) + (last ? "" : "/");
170
+ }
171
+ }
172
+ return new RegExp(`^${source}$`);
173
+ } catch (error) {
174
+ const reason = error instanceof Error ? error.message : String(error);
175
+ throw new TypeError(`invalid glob "${pattern}": ${reason}`, { cause: error });
176
+ }
177
+ }
178
+
179
+ /** Whether a pattern has a `{` that is never closed (a typo that would otherwise match nothing). */
180
+ function hasUnclosedBrace(pattern: string): boolean {
181
+ let depth = 0;
182
+ for (let i = 0; i < pattern.length; i++) {
183
+ const ch = pattern[i];
184
+ if (ch === "\\") {
185
+ i += 1;
186
+ } else if (ch === "{") {
187
+ depth += 1;
188
+ } else if (ch === "}" && depth > 0) {
189
+ depth -= 1;
190
+ }
191
+ }
192
+ return depth > 0;
193
+ }
194
+
195
+ /**
196
+ * Whether a pattern is a bare name that could be a folder: no wildcard and a last segment with no
197
+ * extension (`internal`, `.obsidian`, `Clients/Acme`, but not `STYLE.md`). Such a pattern names the
198
+ * folder as well as a file of that name, the way `.gitignore` reads it, so `exclude: ["internal"]`
199
+ * does what it says instead of excluding nothing.
200
+ */
201
+ function mayNameDirectory(pattern: string): boolean {
202
+ const cleaned = cleanPattern(pattern);
203
+ if (cleaned === "" || /[*?[\\]/.test(cleaned) || cleaned.endsWith("**")) {
204
+ return false;
205
+ }
206
+ const last = cleaned.slice(cleaned.lastIndexOf("/") + 1);
207
+ return !last.slice(1).includes(".");
208
+ }
209
+
210
+ /**
211
+ * Compile an `exclude` list. Throws a readable error for a malformed list so a typo is a build
212
+ * failure naming the pattern rather than a file that quietly stays in.
213
+ *
214
+ * @param {readonly string[]} patterns
215
+ * @returns {ExcludeMatcher}
216
+ */
217
+ export function compileExclude(patterns?: readonly string[]): ExcludeMatcher {
218
+ if (patterns === undefined) {
219
+ return { empty: true, excludedBy: nothing, excludesDir: () => false };
220
+ }
221
+ if (!Array.isArray(patterns)) {
222
+ throw new TypeError(`"exclude" must be an array of glob patterns`);
223
+ }
224
+ const files: { pattern: string; re: RegExp }[] = [];
225
+ const dirs: RegExp[] = [];
226
+ let everything = false;
227
+ for (const pattern of patterns) {
228
+ if (typeof pattern !== "string" || pattern.trim() === "") {
229
+ throw new TypeError(
230
+ `"exclude" entries must be non-empty strings, got ${JSON.stringify(pattern)}`,
231
+ );
232
+ }
233
+ if (pattern.startsWith("!")) {
234
+ throw new TypeError(
235
+ `"exclude" pattern "${pattern}": negated patterns are not supported, list what to exclude instead`,
236
+ );
237
+ }
238
+ if (/^(?:\.\/)?\.\.(?:\/|$)/.test(pattern)) {
239
+ throw new TypeError(
240
+ `"exclude" pattern "${pattern}": patterns are relative to the source directory and cannot climb out of it`,
241
+ );
242
+ }
243
+ if (hasUnclosedBrace(pattern)) {
244
+ throw new TypeError(`"exclude" pattern "${pattern}": a "{" is never closed`);
245
+ }
246
+ for (const expanded of expandBraces(pattern)) {
247
+ files.push({ pattern, re: globToRegExp(expanded) });
248
+ const cleaned = cleanPattern(expanded);
249
+ if (cleaned === "**") {
250
+ everything = true;
251
+ } else if (cleaned.endsWith("/**")) {
252
+ dirs.push(globToRegExp(cleaned.slice(0, -"/**".length)));
253
+ } else if (mayNameDirectory(expanded)) {
254
+ // A bare name excludes the folder of that name too (see mayNameDirectory).
255
+ files.push({ pattern, re: globToRegExp(`${cleaned}/**`) });
256
+ dirs.push(globToRegExp(cleaned));
257
+ }
258
+ }
259
+ }
260
+ return {
261
+ empty: files.length === 0,
262
+ excludesDir: (dir) => everything || dirs.some((re) => re.test(dir)),
263
+ excludedBy: (path) => files.find((f) => f.re.test(path))?.pattern,
264
+ };
265
+ }
266
+
267
+ // ─── where: a declarative frontmatter filter ────────────────────────────────
268
+
269
+ /** Whether an entry's frontmatter passes a compiled `where`. */
270
+ export interface WherePredicate {
271
+ (data: Record<string, unknown>): boolean;
272
+ /**
273
+ * The first field whose condition `data` fails, for a message that says why an entry was left
274
+ * out.
275
+ */
276
+ why: (data: Record<string, unknown>) => string | undefined;
277
+ }
278
+
279
+ /** The query operators. Closed on purpose: see the module comment. */
280
+ const OPERATORS = new Set(["$eq", "$ne", "$in", "$nin", "$exists", "$gt", "$gte", "$lt", "$lte"]);
281
+
282
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
283
+ return (
284
+ typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date)
285
+ );
286
+ }
287
+
288
+ /** A frontmatter value in the form a filter compares: YAML dates become RFC 3339 text. */
289
+ function comparable(value: unknown): unknown {
290
+ if (value instanceof Date) {
291
+ if (Number.isNaN(value.getTime())) {
292
+ return undefined;
293
+ }
294
+ const iso = value.toISOString();
295
+ return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso.replace(/\.\d{3}Z$/, "Z");
296
+ }
297
+ return value;
298
+ }
299
+
300
+ function deepEqual(a: unknown, b: unknown): boolean {
301
+ if (a === b) {
302
+ return true;
303
+ }
304
+ if (Array.isArray(a) && Array.isArray(b)) {
305
+ return (
306
+ a.length === b.length && a.every((item, i) => deepEqual(comparable(item), comparable(b[i])))
307
+ );
308
+ }
309
+ if (isPlainObject(a) && isPlainObject(b)) {
310
+ const keys = Object.keys(a);
311
+ return (
312
+ keys.length === Object.keys(b).length &&
313
+ keys.every((key) => key in b && deepEqual(comparable(a[key]), comparable(b[key])))
314
+ );
315
+ }
316
+ return false;
317
+ }
318
+
319
+ /**
320
+ * Equality, with one convenience that earns its keep: a scalar matches an array field that CONTAINS
321
+ * it, so `{"tags": "frappe"}` selects every entry tagged frappe without a second operator. An array
322
+ * or object operand compares as a whole value.
323
+ */
324
+ function sameValue(actual: unknown, expected: unknown): boolean {
325
+ const a = comparable(actual);
326
+ if (Array.isArray(expected) || isPlainObject(expected)) {
327
+ return deepEqual(a, expected);
328
+ }
329
+ if (Array.isArray(a)) {
330
+ return a.some((item) => comparable(item) === expected);
331
+ }
332
+ return a === expected;
333
+ }
334
+
335
+ /** Read a frontmatter field: a literal key first, then a dotted path into nested data. */
336
+ export function readField(data: Record<string, unknown>, key: string): unknown {
337
+ // Own properties only: `constructor` and `toString` are not frontmatter, whatever `in` says.
338
+ if (Object.hasOwn(data, key)) {
339
+ return data[key];
340
+ }
341
+ if (!key.includes(".")) {
342
+ return undefined;
343
+ }
344
+ let current: unknown = data;
345
+ for (const part of key.split(".")) {
346
+ if (!isPlainObject(current) || !Object.hasOwn(current, part)) {
347
+ return undefined;
348
+ }
349
+ current = current[part];
350
+ }
351
+ return current;
352
+ }
353
+
354
+ /** Order two comparable values; undefined when they are not the same kind of thing. */
355
+ function order(actual: unknown, operand: string | number): number | undefined {
356
+ const a = comparable(actual);
357
+ if (typeof a === "number" && typeof operand === "number") {
358
+ return a - operand;
359
+ }
360
+ if (typeof a === "string" && typeof operand === "string") {
361
+ return a < operand ? -1 : a > operand ? 1 : 0;
362
+ }
363
+ return undefined;
364
+ }
365
+
366
+ type FieldTest = (actual: unknown) => boolean;
367
+
368
+ function operatorTest(where: string, op: string, operand: unknown): FieldTest {
369
+ switch (op) {
370
+ case "$eq": {
371
+ return (actual) => sameValue(actual, operand);
372
+ }
373
+ case "$ne": {
374
+ return (actual) => !sameValue(actual, operand);
375
+ }
376
+ case "$in":
377
+ case "$nin": {
378
+ if (!Array.isArray(operand)) {
379
+ throw new TypeError(`${where}.${op}: expected an array, got ${JSON.stringify(operand)}`);
380
+ }
381
+ const hit: FieldTest = (actual) => operand.some((candidate) => sameValue(actual, candidate));
382
+ return op === "$in" ? hit : (actual) => !hit(actual);
383
+ }
384
+ case "$exists": {
385
+ if (typeof operand !== "boolean") {
386
+ throw new TypeError(
387
+ `${where}.$exists: expected true or false, got ${JSON.stringify(operand)}`,
388
+ );
389
+ }
390
+ // An empty YAML key is null, and "set to nothing" is not a value an author means by "exists".
391
+ return (actual) => (actual !== undefined && actual !== null) === operand;
392
+ }
393
+ default: {
394
+ if (typeof operand !== "string" && typeof operand !== "number") {
395
+ throw new TypeError(
396
+ `${where}.${op}: expected a string or number, got ${JSON.stringify(operand)}`,
397
+ );
398
+ }
399
+ const test = (diff: number) =>
400
+ op === "$gt" ? diff > 0 : op === "$gte" ? diff >= 0 : op === "$lt" ? diff < 0 : diff <= 0;
401
+ return (actual) => {
402
+ const diff = order(actual, operand);
403
+ return diff !== undefined && test(diff);
404
+ };
405
+ }
406
+ }
407
+ }
408
+
409
+ /** A condition object is an operator set when every key is `$`-prefixed. */
410
+ function operatorKeys(condition: Record<string, unknown>): string[] | null {
411
+ const keys = Object.keys(condition);
412
+ const dollars = keys.filter((key) => key.startsWith("$"));
413
+ if (dollars.length === 0) {
414
+ return null;
415
+ }
416
+ return dollars.length === keys.length ? keys : [];
417
+ }
418
+
419
+ function compileCondition(key: string, condition: unknown): FieldTest {
420
+ const where = `where.${key}`;
421
+ if (!isPlainObject(condition)) {
422
+ return (actual) => sameValue(actual, condition);
423
+ }
424
+ const ops = operatorKeys(condition);
425
+ if (ops === null) {
426
+ // A plain object with no operators is a literal value to compare whole.
427
+ return (actual) => deepEqual(comparable(actual), condition);
428
+ }
429
+ if (ops.length === 0) {
430
+ throw new TypeError(`${where}: cannot mix $operators with plain keys in one condition`);
431
+ }
432
+ const tests = ops.map((op) => {
433
+ if (!OPERATORS.has(op)) {
434
+ throw new TypeError(`${where}: unknown operator "${op}" (use ${[...OPERATORS].join(", ")})`);
435
+ }
436
+ return operatorTest(where, op, condition[op]);
437
+ });
438
+ return (actual) => tests.every((test) => test(actual));
439
+ }
440
+
441
+ /**
442
+ * Compile a `where` filter into a predicate over an entry's frontmatter.
443
+ *
444
+ * Every key is a frontmatter field (a dotted path reaches into nested data), keys combine with AND,
445
+ * and a value is either a literal to equal or an operator object (`$eq`, `$ne`, `$in`, `$nin`,
446
+ * `$exists`, `$gt`, `$gte`, `$lt`, `$lte`; several in one object combine with AND too). There is no
447
+ * OR and no evaluation of text: a project that needs "either" splits it into two content types.
448
+ *
449
+ * @param {unknown} where
450
+ * @returns {WherePredicate}
451
+ */
452
+ export function compileWhere(where?: unknown): WherePredicate {
453
+ if (where === undefined) {
454
+ return Object.assign(() => true, { why: nothing });
455
+ }
456
+ if (!isPlainObject(where)) {
457
+ throw new TypeError(`"where" must be an object mapping frontmatter fields to conditions`);
458
+ }
459
+ const tests = Object.entries(where).map(([key, condition]) => {
460
+ if (key.startsWith("$")) {
461
+ throw new TypeError(
462
+ `where.${key}: top-level operators are not supported, conditions on different fields already combine with AND`,
463
+ );
464
+ }
465
+ return { key, test: compileCondition(key, condition) };
466
+ });
467
+ return Object.assign(
468
+ (data: Record<string, unknown>) => tests.every(({ key, test }) => test(readField(data, key))),
469
+ {
470
+ why: (data: Record<string, unknown>) =>
471
+ tests.find(({ key, test }) => !test(readField(data, key)))?.key,
472
+ },
473
+ );
474
+ }
package/src/content.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  * queryContentType, findEntry) used by both the classes and the server endpoint.
7
7
  */
8
8
 
9
+ import { localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
10
+ import type { ProjectConfig } from "@jxsuite/schema/types";
9
11
  import { parseComparable } from "./dates.ts";
10
12
  import type { ContentLoaderEntry } from "./types.ts";
11
13
 
@@ -29,11 +31,15 @@ interface EntryConfig {
29
31
  _project?: {
30
32
  /** The loaded `content` section (Content.projectData result) under its section key. */
31
33
  content?: Map<string, ContentLoaderEntry[]>;
34
+ /** The project config, for the i18n routing rules a locale prefix comes from. */
35
+ config?: ProjectConfig | null;
32
36
  [k: string]: unknown;
33
37
  };
34
38
  _document?: {
35
39
  route?: {
36
40
  _pathParams?: Record<string, string>;
41
+ /** The concrete URL of the page being built (`/kb/frappe/bench-operations`). */
42
+ urlPattern?: string;
37
43
  /** The language this route serves, when the project declares any (§13.4). */
38
44
  locale?: string | null;
39
45
  };
@@ -219,6 +225,24 @@ export class ContentEntry {
219
225
  this.config = config;
220
226
  }
221
227
 
228
+ /**
229
+ * The route the page being built is at, without its locale prefix or trailing slash: the key an
230
+ * entry of a content type with a `route` is found by when the page names no id.
231
+ */
232
+ #pageRoute(): string | undefined {
233
+ const { _project, _document } = this.config;
234
+ let path = _document?.route?.urlPattern;
235
+ if (typeof path !== "string") {
236
+ return undefined;
237
+ }
238
+ const { i18n } = resolveI18n(_project?.config ?? {});
239
+ const prefix = localeUrlPrefix(_document?.route?.locale, i18n);
240
+ if (prefix !== "" && (path === prefix || path.startsWith(`${prefix}/`))) {
241
+ path = path.slice(prefix.length);
242
+ }
243
+ return path.replace(/\/+$/, "") || "/";
244
+ }
245
+
222
246
  resolve() {
223
247
  const { contentType, id, field, _project, _document } = this.config;
224
248
  const entries = _project?.content?.get(contentType ?? "");
@@ -226,19 +250,6 @@ export class ContentEntry {
226
250
  return null;
227
251
  }
228
252
 
229
- let resolvedId = id;
230
- if (
231
- resolvedId &&
232
- typeof resolvedId === "object" &&
233
- (resolvedId as { $ref?: string }).$ref?.startsWith("#/$params/")
234
- ) {
235
- const paramName = (resolvedId as { $ref: string }).$ref.replace("#/$params/", "");
236
- resolvedId = _document?.route?._pathParams?.[paramName];
237
- }
238
- if (!resolvedId) {
239
- return null;
240
- }
241
-
242
253
  /*
243
254
  * Scoped to the route's language, exactly as `resolvePaths` scopes an expansion and for the
244
255
  * same reason: two translations of one entry share an id (site-architecture.md §13.3), so an
@@ -256,6 +267,31 @@ export class ContentEntry {
256
267
  ? entries.filter((e: ContentLoaderEntry) => e._meta?.locale === wanted)
257
268
  : entries;
258
269
 
270
+ /*
271
+ * A page that names no `id` is bound to the entry its URL belongs to. That is how a content
272
+ * type with a `route` is consumed: `$paths` generated this page FROM that entry's route, so
273
+ * the route is the join, and neither the page nor the template repeats a parameter name.
274
+ */
275
+ if (id === undefined && field === undefined) {
276
+ const route = this.#pageRoute();
277
+ return route === undefined
278
+ ? null
279
+ : (scoped.find((e: ContentLoaderEntry) => e._meta?.route === route) ?? null);
280
+ }
281
+
282
+ let resolvedId = id;
283
+ if (
284
+ resolvedId &&
285
+ typeof resolvedId === "object" &&
286
+ (resolvedId as { $ref?: string }).$ref?.startsWith("#/$params/")
287
+ ) {
288
+ const paramName = (resolvedId as { $ref: string }).$ref.replace("#/$params/", "");
289
+ resolvedId = _document?.route?._pathParams?.[paramName];
290
+ }
291
+ if (!resolvedId) {
292
+ return null;
293
+ }
294
+
259
295
  if (field && field !== "id") {
260
296
  return scoped.find((e: ContentLoaderEntry) => e.data[field] === resolvedId) ?? null;
261
297
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * A TextMate grammar for the Caddyfile, the one language the knowledge-base conventions name that
3
+ * Shiki does not ship.
4
+ *
5
+ * Deliberately small: a Caddyfile has no real syntax tree to highlight, only a column-0 site
6
+ * address, a directive at the start of each line, and arguments that are strings, placeholders,
7
+ * named matchers, durations or paths. That is enough to make a reverse-proxy snippet readable, and
8
+ * every pattern here is plain enough for the JavaScript regex engine the highlighter runs on, which
9
+ * has no Oniguruma-only constructs to lean on.
10
+ *
11
+ * @module @jxsuite/parser/grammars/caddyfile
12
+ * @license MIT
13
+ * @docs framework/site/jx-markdown
14
+ */
15
+
16
+ import type { LanguageRegistration } from "shiki/core";
17
+
18
+ export const caddyfile: LanguageRegistration = {
19
+ displayName: "Caddyfile",
20
+ name: "caddyfile",
21
+ patterns: [
22
+ { include: "#comment" },
23
+ { include: "#site-address" },
24
+ { include: "#directive" },
25
+ { include: "#string" },
26
+ { include: "#placeholder" },
27
+ { include: "#matcher" },
28
+ { include: "#duration" },
29
+ { include: "#constant" },
30
+ { include: "#number" },
31
+ { include: "#punctuation" },
32
+ ],
33
+ repository: {
34
+ comment: {
35
+ match: "(?:^|(?<=\\s))#.*$",
36
+ name: "comment.line.number-sign.caddyfile",
37
+ },
38
+ constant: {
39
+ match: "\\b(?:on|off|true|false|h1|h2|h2c|h3)\\b",
40
+ name: "constant.language.caddyfile",
41
+ },
42
+ directive: {
43
+ /* The first word of a line is a directive or subdirective. A word that carries a dot or a
44
+ colon straight after it (`example.com`, `http://…`) is a site address, not a directive. */
45
+ match: "^\\s*([A-Za-z_][\\w]*)(?=\\s|$)",
46
+ captures: { "1": { name: "entity.name.function.directive.caddyfile" } },
47
+ },
48
+ duration: {
49
+ match: "\\b\\d+(?:\\.\\d+)?(?:ns|us|ms|s|m|h|d)\\b",
50
+ name: "constant.numeric.duration.caddyfile",
51
+ },
52
+ matcher: {
53
+ match: "(?<![\\w])@[\\w-]+",
54
+ name: "entity.name.tag.matcher.caddyfile",
55
+ },
56
+ number: {
57
+ match: "\\b\\d+(?:\\.\\d+)?\\b",
58
+ name: "constant.numeric.caddyfile",
59
+ },
60
+ placeholder: {
61
+ match: "\\{[$%]?[\\w.>/:-]+\\}",
62
+ name: "constant.other.placeholder.caddyfile",
63
+ },
64
+ punctuation: {
65
+ match: "[{}]",
66
+ name: "punctuation.section.block.caddyfile",
67
+ },
68
+ "site-address": {
69
+ // A column-0 line that opens a block and names a host, a port or a wildcard.
70
+ match: "^(?=\\S)(?:[^\\s{#,]*[.:*/][^\\s{#,]*)(?:\\s*,\\s*[^\\s{#,]+)*(?=\\s*\\{\\s*$)",
71
+ name: "entity.name.type.address.caddyfile",
72
+ },
73
+ string: {
74
+ patterns: [
75
+ {
76
+ begin: '"',
77
+ end: '"',
78
+ name: "string.quoted.double.caddyfile",
79
+ patterns: [{ match: "\\\\.", name: "constant.character.escape.caddyfile" }],
80
+ },
81
+ { begin: "`", end: "`", name: "string.quoted.other.caddyfile" },
82
+ ],
83
+ },
84
+ },
85
+ scopeName: "source.caddyfile",
86
+ };