@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/src/highlight.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  /**
2
2
  * Build-time syntax highlighting for fenced code blocks (node-only).
3
3
  *
4
- * A synchronous Shiki core (JavaScript regex engine — no WASM, no async) tokenizes a fixed
5
- * grammar set against a light + dark GitHub theme pair. Tokens are emitted as span elements
6
- * carrying both colors as CSS custom properties (--shiki-light / --shiki-dark); the page's
7
- * stylesheet decides which one paints, so highlighting follows the color-scheme contract
8
- * (spec §9.5) for auto and forced schemes alike.
4
+ * A synchronous Shiki core (JavaScript regex engine — no WASM, no async) tokenizes a fixed grammar
5
+ * set (see `getHighlighter` for the list) against a light + dark GitHub theme pair. Tokens are
6
+ * emitted as span elements carrying both colors as CSS custom properties (--shiki-light /
7
+ * --shiki-dark); the page's stylesheet decides which one paints, so highlighting follows the
8
+ * color-scheme contract (spec §9.5) for auto and forced schemes alike.
9
9
  *
10
10
  * Lives outside the browser-safe transpile module: only the compile-time markdown path
11
11
  * (processMarkdown in md.ts) pays for the grammars.
12
12
  *
13
- * @docs framework/site/jx-markdown
14
13
  * @module @jxsuite/parser/highlight
15
14
  * @license MIT
15
+ * @docs framework/site/jx-markdown
16
16
  */
17
17
 
18
18
  import { createHighlighterCoreSync } from "shiki/core";
@@ -20,15 +20,28 @@ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
20
20
  import githubDark from "@shikijs/themes/github-dark-default";
21
21
  import githubLight from "@shikijs/themes/github-light-default";
22
22
  import css from "@shikijs/langs/css";
23
+ import diff from "@shikijs/langs/diff";
24
+ import dockerfile from "@shikijs/langs/dockerfile";
23
25
  import html from "@shikijs/langs/html";
26
+ import ini from "@shikijs/langs/ini";
24
27
  import javascript from "@shikijs/langs/javascript";
25
28
  import json from "@shikijs/langs/json";
29
+ import jsonc from "@shikijs/langs/jsonc";
26
30
  import markdown from "@shikijs/langs/markdown";
31
+ import nginx from "@shikijs/langs/nginx";
32
+ import nix from "@shikijs/langs/nix";
33
+ import php from "@shikijs/langs/php";
34
+ import python from "@shikijs/langs/python";
35
+ import ruby from "@shikijs/langs/ruby";
27
36
  import shellscript from "@shikijs/langs/shellscript";
37
+ import sql from "@shikijs/langs/sql";
38
+ import toml from "@shikijs/langs/toml";
28
39
  import typescript from "@shikijs/langs/typescript";
40
+ import xml from "@shikijs/langs/xml";
29
41
  import yaml from "@shikijs/langs/yaml";
30
42
  import type { JxElement } from "@jxsuite/schema/types";
31
43
  import type { HighlighterCore } from "shiki/core";
44
+ import { caddyfile } from "./grammars/caddyfile.ts";
32
45
 
33
46
  const THEMES = { dark: "github-dark-default", light: "github-light-default" } as const;
34
47
 
@@ -38,7 +51,32 @@ let _highlighter: HighlighterCore | null = null;
38
51
  function getHighlighter(): HighlighterCore {
39
52
  _highlighter ??= createHighlighterCoreSync({
40
53
  engine: createJavaScriptRegexEngine(),
41
- langs: [json, typescript, javascript, markdown, html, shellscript, css, yaml],
54
+ langs: [
55
+ json,
56
+ jsonc,
57
+ typescript,
58
+ javascript,
59
+ markdown,
60
+ html,
61
+ shellscript,
62
+ css,
63
+ yaml,
64
+ /* The languages a technical knowledge base reaches for next: server config, scripting,
65
+ databases, data files, and patches. They were chosen by reading every fence in a real
66
+ operations vault, and tests/highlight.test.ts tokenizes a sample of each. */
67
+ sql,
68
+ php,
69
+ python,
70
+ ruby,
71
+ nix,
72
+ nginx,
73
+ caddyfile,
74
+ toml,
75
+ ini,
76
+ diff,
77
+ xml,
78
+ dockerfile,
79
+ ],
42
80
  themes: [githubLight, githubDark],
43
81
  });
44
82
  return _highlighter;
@@ -49,11 +87,13 @@ function getHighlighter(): HighlighterCore {
49
87
  * unknown languages (callers keep the plain-text fallback).
50
88
  *
51
89
  * @param {string} code
52
- * @param {string} lang - Fence info string (grammar name or registered alias)
90
+ * @param {string} info - Fence info string (grammar name or registered alias, any case)
53
91
  * @returns {(JxElement | string)[] | null}
54
92
  */
55
- export function highlightFence(code: string, lang: string): (JxElement | string)[] | null {
93
+ export function highlightFence(code: string, info: string): (JxElement | string)[] | null {
56
94
  const h = getHighlighter();
95
+ // Grammar names and aliases are lower case; an author's fence is whatever they typed (```SQL).
96
+ const lang = info.toLowerCase();
57
97
  if (!h.getLoadedLanguages().includes(lang)) {
58
98
  return null;
59
99
  }
package/src/inert.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Inert — making template-looking text in Markdown content stay text.
3
+ *
4
+ * Jx treats any string that contains `${` as a template and evaluates it, at build time and again
5
+ * in the browser (`isTemplateString`, `@jxsuite/schema/guards`). That is the right rule for a
6
+ * document an author wrote, and the wrong one for content: the body of a Markdown entry is prose
7
+ * and code samples, and a JavaScript template literal, a shell `${HOME}` or a GitHub Actions `${{
8
+ * secrets.TOKEN }}` in a fence is **text to show**, not an expression to run. Left alone, a
9
+ * highlighted fence containing `${` had its tokens turned into reactive bindings whose value is
10
+ * whatever the page's state says (usually nothing), so the sample silently lost the very code it
11
+ * was there to show.
12
+ *
13
+ * The framework's own guidance for content that reaches the tree is to escape `${` first (spec.md
14
+ * §21.1), and the compiler already has an idiom for it: it writes `${` into `innerHTML` so "the
15
+ * compile phase won't re-interpret" the result as a template. This module applies that idiom at the
16
+ * source, to the one producer that knows its text is content. A node whose `textContent` holds `${`
17
+ * gets the same text as HTML-escaped `innerHTML`, and a bare string child holding `${` becomes a
18
+ * `span` doing the same, so every other part of the pipeline sees nothing it could mistake for a
19
+ * template.
20
+ *
21
+ * It runs last, after heading ids and the table of contents are built from the text, because those
22
+ * read `textContent`.
23
+ *
24
+ * **Attribute values are different, and the rule is stated rather than hidden.** An entity cannot
25
+ * be written into an attribute (the serializer would escape its `&`), and a link's `href` or an
26
+ * image's `src` may legitimately name a template, so those two are left to the page author. The
27
+ * attributes that carry prose are made inert instead: `alt` and `title` (an image's description, a
28
+ * link's tooltip) and `data-title` (a callout's title), by writing a zero-width space between the
29
+ * `$` and the `{`. A note that says "set `${HOME}`" in an image description would otherwise become
30
+ * a binding whose evaluation throws in every visitor's browser, and the page would stop hydrating.
31
+ *
32
+ * Pure: no `node:` imports.
33
+ *
34
+ * @module @jxsuite/parser/inert
35
+ * @license MIT
36
+ * @docs framework/site/jx-markdown
37
+ */
38
+
39
+ import type { JxElement } from "@jxsuite/schema/types";
40
+
41
+ /**
42
+ * A content tree. A raw HTML block arrives as a nested array of nodes, so the shape is recursive
43
+ * and every walk over it has to descend into arrays as well as into `children`.
44
+ */
45
+ export type ContentNodes = (JxElement | string | ContentNodes)[];
46
+
47
+ /** HTML-escape text, and spell `${` as `${` so nothing downstream reads it as a template. */
48
+ export function escapeTemplateText(text: string): string {
49
+ return text
50
+ .replaceAll("&", "&")
51
+ .replaceAll("<", "&lt;")
52
+ .replaceAll(">", "&gt;")
53
+ .replaceAll("${", "&#36;{");
54
+ }
55
+
56
+ /** The attributes whose value is prose, so a `${` in one is text and never a binding. */
57
+ const PROSE_ATTRIBUTES = ["alt", "title", "data-title"] as const;
58
+
59
+ /** `${` spelled so an attribute value is not read as a template: a zero-width space splits it. */
60
+ export function neutralizeTemplateAttribute(value: string): string {
61
+ return value.replaceAll("${", "$\u200B{");
62
+ }
63
+
64
+ /**
65
+ * Make every `${` in a content tree plain text, in place.
66
+ *
67
+ * @param {ContentNodes} nodes - An entry's `$children`
68
+ */
69
+ export function makeTemplatesInert(nodes: ContentNodes): void {
70
+ for (const [i, node] of nodes.entries()) {
71
+ if (typeof node === "string") {
72
+ if (node.includes("${")) {
73
+ nodes[i] = { innerHTML: escapeTemplateText(node), tagName: "span" };
74
+ }
75
+ continue;
76
+ }
77
+ if (Array.isArray(node)) {
78
+ makeTemplatesInert(node);
79
+ continue;
80
+ }
81
+ if (typeof node !== "object" || node === null) {
82
+ continue;
83
+ }
84
+ if (typeof node.textContent === "string" && node.textContent.includes("${")) {
85
+ node.innerHTML = escapeTemplateText(node.textContent);
86
+ delete node.textContent;
87
+ }
88
+ const { attributes } = node;
89
+ if (attributes) {
90
+ for (const name of PROSE_ATTRIBUTES) {
91
+ const value = attributes[name];
92
+ if (typeof value === "string" && value.includes("${")) {
93
+ attributes[name] = neutralizeTemplateAttribute(value);
94
+ }
95
+ }
96
+ }
97
+ if (Array.isArray(node.children)) {
98
+ makeTemplatesInert(node.children as ContentNodes);
99
+ }
100
+ }
101
+ }
package/src/markdown.ts CHANGED
@@ -2,19 +2,20 @@
2
2
  * Markdown — the markdown format-extension class for Jx
3
3
  *
4
4
  * Single class carrying every format capability:
5
- * - static `parse` (markdown source → Jx document) — browser-safe
6
- * - static `serialize` (Jx document → markdown source) — browser-safe
7
- * - static `discover` / `load` (compile-time content access) — node-only, dynamic imports
8
- * - instance `resolve` (runtime on-demand access for `$prototype: "Markdown"` state)
9
5
  *
10
- * The node-only capabilities dynamically import `node:fs` / `./md.ts` inside the
11
- * method so this module stays importable in the browser (studio calls parse/serialize
12
- * in-process).
6
+ * - Static `parse` (markdown source → Jx document) — browser-safe
7
+ * - Static `serialize` (Jx document → markdown source) — browser-safe
8
+ * - Static `discover` / `load` (compile-time content access) — node-only, dynamic imports
9
+ * - Instance `resolve` (runtime on-demand access for `$prototype: "Markdown"` state)
10
+ *
11
+ * The node-only capabilities dynamically import `node:fs` / `./md.ts` inside the method so this
12
+ * module stays importable in the browser (studio calls parse/serialize in-process).
13
13
  *
14
14
  * @module @jxsuite/parser/markdown
15
15
  * @license MIT
16
16
  */
17
17
 
18
+ import { compileExclude } from "./content-rules.ts";
18
19
  import { transpileJxMarkdown } from "./transpile.ts";
19
20
  import { serializeJxMarkdown } from "./serialize.ts";
20
21
  import type { SerializeOptions } from "./serialize.ts";
@@ -32,6 +33,24 @@ export interface MarkdownLoadOptions {
32
33
  * basename ids.
33
34
  */
34
35
  sourceRoot?: string;
36
+ /**
37
+ * The content type's `alerts` option: alert type → custom element, or `false` to leave GitHub
38
+ * alerts (`> [!NOTE]`) as plain blockquotes. Absent renders the built-in callout markup.
39
+ */
40
+ alerts?: unknown;
41
+ /** Told of each `[!type]` marker whose type is not enabled (it stays a blockquote). */
42
+ onUnknownAlert?: (type: string) => void;
43
+ }
44
+
45
+ /** Options for {@link Markdown.discover}. */
46
+ export interface MarkdownDiscoverOptions {
47
+ /** Directory the `source` is relative to. */
48
+ baseDir?: string;
49
+ /**
50
+ * Glob patterns, relative to the source directory, for files (and whole directories) to leave
51
+ * out. A directory every file of which is excluded is not read at all.
52
+ */
53
+ exclude?: readonly string[];
35
54
  }
36
55
 
37
56
  /**
@@ -58,22 +77,27 @@ export class Markdown {
58
77
  return serializeJxMarkdown(doc, options);
59
78
  }
60
79
 
61
- /** List .md entry files for a content-type source (file path or directory). */
62
- static async discover(source: string, options: { baseDir?: string } = {}): Promise<string[]> {
63
- const { existsSync, readdirSync } = await import("node:fs");
80
+ /**
81
+ * List .md entry files for a content-type source (file path or directory).
82
+ *
83
+ * A directory is walked in sorted order, so the entries of a collection come back in the same
84
+ * order on every machine. The platform's own directory order is whatever the filesystem happens
85
+ * to return, which made an unsorted listing differ between a laptop and CI.
86
+ */
87
+ static async discover(source: string, options: MarkdownDiscoverOptions = {}): Promise<string[]> {
88
+ const { existsSync } = await import("node:fs");
64
89
  const { resolve, extname } = await import("node:path");
90
+ const { walkFiles } = await import("./walk.ts");
65
91
  const resolved = options.baseDir ? resolve(options.baseDir, source) : resolve(source);
66
92
 
67
93
  if (extname(resolved)) {
68
94
  return existsSync(resolved) ? [resolved] : [];
69
95
  }
70
- try {
71
- return readdirSync(resolved, { recursive: true })
72
- .filter((f) => String(f).endsWith(".md"))
73
- .map((f) => resolve(resolved, String(f)));
74
- } catch {
75
- return [];
76
- }
96
+ return walkFiles(
97
+ resolved,
98
+ (name) => name.toLowerCase().endsWith(".md"),
99
+ compileExclude(options.exclude),
100
+ );
77
101
  }
78
102
 
79
103
  /** Load one markdown file into a content entry (frontmatter → data, body preserved). */
@@ -90,6 +114,8 @@ export class Markdown {
90
114
  directiveOptions: options.directiveOptions,
91
115
  }),
92
116
  ...(options.sourceRoot !== undefined && { sourceRoot: options.sourceRoot }),
117
+ ...(options.alerts !== undefined && { alerts: options.alerts }),
118
+ ...(options.onUnknownAlert !== undefined && { onUnknownAlert: options.onUnknownAlert }),
93
119
  });
94
120
  const _meta: ContentLoaderEntry["_meta"] = {};
95
121
  /*
package/src/md.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * Jxsuite/md — Markdown integration for Jx
3
3
  *
4
4
  * Provides two exports:
5
- * - MarkdownFile — Parse a single markdown file (external class for $prototype)
6
- * - MarkdownCollection — Parse a glob of markdown files as a content collection
5
+ *
6
+ * - MarkdownFile — Parse a single markdown file (external class for $prototype)
7
+ * - MarkdownCollection — Parse a glob of markdown files as a content collection
7
8
  *
8
9
  * Built on the unified/remark ecosystem. Converts MDAST to JX node trees via mdastNodeToJx.
9
10
  *
@@ -21,8 +22,10 @@ import { readFileSync } from "node:fs";
21
22
  import { DEFAULT_FORMAT_LOCALE } from "@jxsuite/schema/intl";
22
23
  import { basename, extname, relative, resolve as resolvePath } from "node:path";
23
24
  import { globSync } from "glob";
25
+ import { normalizeAlerts, transformAlerts } from "./alerts.ts";
24
26
  import { assignHeadingIds, mdastNodeToJx } from "./transpile.ts";
25
27
  import { highlightCodeBlocks } from "./highlight.ts";
28
+ import { makeTemplatesInert } from "./inert.ts";
26
29
  import type { MarkdownFileResult, MdastNode, UnifiedProcessor } from "./types.ts";
27
30
  import type { JxElement } from "@jxsuite/schema/types";
28
31
 
@@ -163,8 +166,12 @@ function extractExcerpt(tree: MdastNode) {
163
166
  /**
164
167
  * Derive an entry slug from a file path. Without a source root (or for files directly at the root),
165
168
  * the slug is the basename — the historical behavior every flat collection relies on. Files in
166
- * subdirectories of the root get path-based slugs with POSIX separators and a trailing `/index`
167
- * stripped, so `studio/canvas.md` and `studio/canvas/index.md` both yield `studio/canvas`.
169
+ * subdirectories of the root get path-based slugs with POSIX separators and a trailing `/index` or
170
+ * `/README` (either case, as the link and route rules read them) stripped, so `studio/canvas.md`,
171
+ * `studio/canvas/index.md` and `studio/canvas/README.md` all yield `studio/canvas`: the file a
172
+ * folder shows (on GitHub, in Obsidian) is that folder's entry. Case and spaces in names are kept
173
+ * as written, so `Linux/Swap Configuration.md` is the id `Linux/Swap Configuration`; a collection
174
+ * that wants URL-friendly ids sets `idField` or slugifies in its `route`.
168
175
  *
169
176
  * @param {string} filePath - Absolute path to the markdown file
170
177
  * @param {string} [sourceRoot] - Resolved content-source root directory
@@ -175,8 +182,8 @@ function deriveSlug(filePath: string, sourceRoot?: string): string {
175
182
  const rel = relative(sourceRoot, filePath).split("\\").join("/");
176
183
  if (rel && !rel.startsWith("..") && rel.includes("/")) {
177
184
  let slug = rel.slice(0, rel.length - extname(rel).length);
178
- if (slug.endsWith("/index")) {
179
- slug = slug.slice(0, -"/index".length);
185
+ if (/\/(?:index|readme)$/i.test(slug)) {
186
+ slug = slug.slice(0, slug.lastIndexOf("/"));
180
187
  }
181
188
  return slug;
182
189
  }
@@ -195,6 +202,10 @@ function deriveSlug(filePath: string, sourceRoot?: string): string {
195
202
  * @param {boolean} [config.directives] - Enable directive support
196
203
  * @param {unknown} [config.directiveOptions] - Directive plugin options
197
204
  * @param {string} [config.sourceRoot] - Content-source root; files below it get path-based slugs
205
+ * @param {unknown} [config.alerts] - The content type's `alerts` option: alert type → element name,
206
+ * or `false` to leave `> [!NOTE]` blockquotes as written. Absent means the built-in callouts.
207
+ * @param {(type: string) => void} [config.onUnknownAlert] - Told of each `[!type]` marker whose
208
+ * type is not enabled, which stays a blockquote.
198
209
  * @returns {MarkdownFileResult}
199
210
  */
200
211
  export function processMarkdown(
@@ -204,6 +215,8 @@ export function processMarkdown(
204
215
  directives?: boolean;
205
216
  directiveOptions?: unknown;
206
217
  sourceRoot?: string;
218
+ alerts?: unknown;
219
+ onUnknownAlert?: (type: string) => void;
207
220
  } = {},
208
221
  ) {
209
222
  let processor = (unified as unknown as () => UnifiedProcessor)()
@@ -220,6 +233,12 @@ export function processMarkdown(
220
233
  const vfile = { data: {} };
221
234
  processor.runSync(tree, vfile);
222
235
 
236
+ // Callouts first, so the excerpt and word count read the alert's own text, not its `[!NOTE]` marker.
237
+ const alerts = normalizeAlerts(config.alerts);
238
+ if (alerts) {
239
+ transformAlerts(tree as unknown as MdastNode, alerts, config.onUnknownAlert);
240
+ }
241
+
223
242
  const vfileData = vfile.data as Record<string, unknown>;
224
243
  const frontmatter = (vfileData.frontmatter ?? {}) as Record<string, unknown>;
225
244
  const mdTree = tree as unknown as MdastNode;
@@ -242,6 +261,10 @@ export function processMarkdown(
242
261
  // Table of contents agree by construction (specs/parser.md).
243
262
  const toc = assignHeadingIds($children);
244
263
 
264
+ // Last, because the ids and the table of contents above read `textContent`. Text that happens
265
+ // To contain `${` is content, not a template (inert.ts).
266
+ makeTemplatesInert($children);
267
+
245
268
  return {
246
269
  $children,
247
270
  $excerpt: excerpt,
package/src/serialize.ts CHANGED
@@ -1152,9 +1152,6 @@ function deepResolve(
1152
1152
  scope: Record<string, unknown>,
1153
1153
  ctx: ExportContext,
1154
1154
  ) {
1155
- if (!Array.isArray(nodes)) {
1156
- return [];
1157
- }
1158
1155
  return nodes.map((node) => resolveNode(node, scope, ctx));
1159
1156
  }
1160
1157
 
package/src/transpile.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Jx Markdown Transpiler — Browser-safe module
3
3
  *
4
- * Exports only the transpiler functions that work in browser environments
5
- * (no node:fs, node:path, or glob dependencies).
4
+ * Exports only the transpiler functions that work in browser environments (no node:fs, node:path,
5
+ * or glob dependencies).
6
6
  *
7
- * Use `@jxsuite/parser/transpile` to import in browser contexts (e.g. studio).
8
- * Use `@jxsuite/parser` for the full parser including MarkdownFile/MarkdownCollection.
7
+ * Use `@jxsuite/parser/transpile` to import in browser contexts (e.g. studio). Use
8
+ * `@jxsuite/parser` for the full parser including MarkdownFile/MarkdownCollection.
9
9
  *
10
10
  * @module @jxsuite/parser/transpile
11
11
  * @license MIT
@@ -18,6 +18,8 @@ import remarkParseFrontmatter from "remark-parse-frontmatter";
18
18
  import remarkGfm from "remark-gfm";
19
19
  import remarkDirective from "remark-directive";
20
20
  import { htmlToJx } from "@jxsuite/markup/html-to-jx";
21
+ import { ALERT_NODE, DEFAULT_ALERT_TITLES, inlineText } from "./alerts.ts";
22
+ import type { AlertNode } from "./alerts.ts";
21
23
  import type { MdastNode, UnifiedProcessor } from "./types.ts";
22
24
  import type {
23
25
  JsonValue,
@@ -389,6 +391,10 @@ export function mdastNodeToJx(node: MdastNode) {
389
391
  return directiveToJx(node);
390
392
  }
391
393
 
394
+ if (node.type === ALERT_NODE) {
395
+ return alertToJx(node as AlertNode);
396
+ }
397
+
392
398
  if (node.type === "text") {
393
399
  return node.value ?? null;
394
400
  }
@@ -516,6 +522,56 @@ export function mdastNodeToJx(node: MdastNode) {
516
522
  return el;
517
523
  }
518
524
 
525
+ /**
526
+ * Convert an alert (`> [!NOTE]`, rewritten by `transformAlerts` before conversion) to a Jx element.
527
+ *
528
+ * Two shapes, chosen by the content type's `alerts` option. A type mapped to an element renders as
529
+ * exactly what `:::that-element` would have produced, with the alert type at `data-alert` and any
530
+ * author title at `data-title`, so the component owns its own look and its own title. A type with
531
+ * no mapping gets built-in markup that stands alone: a `div` with `role="note"` whose first child
532
+ * is a visible title paragraph, and class hooks (`jx-alert`, `jx-alert-<type>`, `jx-alert-title`)
533
+ * for the page's stylesheet. The title is a `p`, not a heading, so a callout never lands in the
534
+ * table of contents or in the search index's section list.
535
+ *
536
+ * @param {AlertNode} node
537
+ * @returns {JxElement}
538
+ */
539
+ function alertToJx(node: AlertNode): JxElement {
540
+ const body = convertChildren(node.children ?? []);
541
+
542
+ if (node.element) {
543
+ const attributes: Record<string, JxAttributeValue> = { "data-alert": node.alert };
544
+ if (node.titleNodes) {
545
+ attributes["data-title"] = inlineText(node.titleNodes).trim();
546
+ }
547
+ const el: JxElement = { attributes, tagName: node.element };
548
+ if (body.length > 0) {
549
+ el.children = body;
550
+ }
551
+ return el;
552
+ }
553
+
554
+ const title: JxElement = { className: "jx-alert-title", tagName: "p" };
555
+ if (node.titleNodes) {
556
+ const inline = convertChildren(node.titleNodes);
557
+ if (inline.length === 1 && typeof inline[0] === "string") {
558
+ [title.textContent] = inline;
559
+ } else {
560
+ title.children = inline;
561
+ }
562
+ } else {
563
+ title.textContent =
564
+ DEFAULT_ALERT_TITLES[node.alert] ??
565
+ `${node.alert.charAt(0).toUpperCase()}${node.alert.slice(1)}`;
566
+ }
567
+ return {
568
+ attributes: { "data-alert": node.alert, role: "note" },
569
+ children: [title, ...body],
570
+ className: `jx-alert jx-alert-${node.alert}`,
571
+ tagName: "div",
572
+ };
573
+ }
574
+
519
575
  /**
520
576
  * Convert a directive mdast node to a Jx element.
521
577
  *
@@ -536,7 +592,8 @@ function directiveToJx(node: MdastNode) {
536
592
  if (isCustomElement) {
537
593
  // For custom elements:
538
594
  // - style, children, textContent, innerHTML, $-prefixed → element-level
539
- // - props (from props.X dot-path) → $props (component state)
595
+ // ($props included: "props", bare or dotted, is a JX_DOLLAR_KEYS entry, so
596
+ // `expandDotPaths` has already renamed it to "$props" by the time this loop sees it)
540
597
  // - everything else → HTML attributes
541
598
  for (const [key, value] of Object.entries(props)) {
542
599
  if (
@@ -547,8 +604,6 @@ function directiveToJx(node: MdastNode) {
547
604
  key.startsWith("$")
548
605
  ) {
549
606
  el[key] = value;
550
- } else if (key === "props") {
551
- el.$props = value as Record<string, JsonValue>;
552
607
  } else {
553
608
  if (!el.attributes) {
554
609
  el.attributes = {};
package/src/types.ts CHANGED
@@ -27,6 +27,23 @@ export interface ContentTypeDef {
27
27
  format?: string;
28
28
  schema?: Record<string, unknown>;
29
29
  $elements?: (string | { $ref: string })[];
30
+ /** Globs, relative to a directory `source`, for files that are never read. */
31
+ exclude?: string[];
32
+ /** Declarative frontmatter filter: only entries that satisfy it are kept. */
33
+ where?: Record<string, unknown>;
34
+ /** Frontmatter field whose value is the entry id (the path-derived id when absent). */
35
+ idField?: string;
36
+ /** Route template giving each entry its URL. */
37
+ route?: string;
38
+ /** Route template for a directory's README.md or index.md (`route` is used when absent). */
39
+ indexRoute?: string;
40
+ /** How a link that has no page is reported. Default `"warn"`. */
41
+ links?: "warn" | "error" | "ignore";
42
+ /**
43
+ * GitHub alert type → custom element, `true` for the built-in markup, `false` to leave it a
44
+ * blockquote. `false` for the whole option turns callouts off, `true` is the default.
45
+ */
46
+ alerts?: Record<string, string | boolean | null> | boolean;
30
47
  [key: string]: unknown;
31
48
  }
32
49
 
package/src/walk.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Walk — the one directory walk behind every collection's file discovery (node-only).
3
+ *
4
+ * Three properties are the reason it is not a bare `readdirSync(dir, { recursive: true })`:
5
+ *
6
+ * - **Sorted.** Entries are visited by name within each directory, depth first, so a collection loads
7
+ * in the same order on every machine. The platform's own directory order is whatever the
8
+ * filesystem returns, and "first one wins" rules (a duplicate id, a duplicate route) must not
9
+ * depend on it.
10
+ * - **Pruned.** A directory an `exclude` pattern excludes whole (`**` + `/node_modules/**`,
11
+ * `internal/**`, a dot-folder) is never read, which is the difference between a vault that sits
12
+ * beside a website's `node_modules` building in a blink and building after listing every file in
13
+ * it. A directory that cannot be read is skipped rather than failing the whole collection.
14
+ * - **Loop-safe.** A symlinked directory is followed, as `readdirSync` always did, because a monorepo
15
+ * that mounts shared documentation into a collection (`content/docs` pointing at `../../docs`) is
16
+ * a real layout. What it refuses is a link back into a directory it is already inside, which
17
+ * would otherwise recurse until the platform gave up.
18
+ *
19
+ * @module @jxsuite/parser/walk
20
+ * @license MIT
21
+ * @docs framework/site/content-collections
22
+ */
23
+
24
+ import { readdirSync, realpathSync, statSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import type { ExcludeMatcher } from "./content-rules.ts";
27
+
28
+ /**
29
+ * Absolute paths of the files under `root` whose name `accept`s and that `exclude` does not hide.
30
+ *
31
+ * A symlink is a file when it points at one and is ignored when it dangles. A symlink to a
32
+ * directory is entered once per path that reaches it, except that a link resolving to a directory
33
+ * the walk is currently inside is skipped, so a link back to an ancestor cannot loop. Links are
34
+ * trusted: where one points is the author's choice, exactly as it is for any other file in the
35
+ * source.
36
+ *
37
+ * @param {string} root - Absolute directory
38
+ * @param {(name: string) => boolean} accept - Whether a file name is an entry file
39
+ * @param {ExcludeMatcher} exclude - The type's `exclude` patterns, matched against root-relative
40
+ * paths
41
+ * @returns {string[]}
42
+ */
43
+ export function walkFiles(
44
+ root: string,
45
+ accept: (name: string) => boolean,
46
+ exclude: ExcludeMatcher,
47
+ ): string[] {
48
+ const found: string[] = [];
49
+ /** The real path of every directory the walk is inside right now, root first. */
50
+ const inside = new Set<string>();
51
+ const real = (path: string): string | undefined => {
52
+ try {
53
+ return realpathSync(path);
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ };
58
+ const walk = (dir: string, rel: string, realDir: string) => {
59
+ let entries;
60
+ try {
61
+ entries = readdirSync(dir, { withFileTypes: true });
62
+ } catch {
63
+ return;
64
+ }
65
+ inside.add(realDir);
66
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
67
+ for (const entry of entries) {
68
+ const path = rel === "" ? entry.name : `${rel}/${entry.name}`;
69
+ const full = join(dir, entry.name);
70
+ let isDirectory = entry.isDirectory();
71
+ let realChild = join(realDir, entry.name);
72
+ let isFile = entry.isFile();
73
+ if (entry.isSymbolicLink()) {
74
+ const target = statSync(full, { throwIfNoEntry: false });
75
+ isDirectory = target?.isDirectory() === true;
76
+ isFile = target?.isFile() === true;
77
+ if (isDirectory) {
78
+ const resolved = real(full);
79
+ if (resolved === undefined) {
80
+ continue;
81
+ }
82
+ realChild = resolved;
83
+ }
84
+ }
85
+ if (isDirectory) {
86
+ if (!inside.has(realChild) && !exclude.excludesDir(path)) {
87
+ walk(full, path, realChild);
88
+ }
89
+ } else if (isFile && accept(entry.name) && !exclude.excludedBy(path)) {
90
+ found.push(full);
91
+ }
92
+ }
93
+ inside.delete(realDir);
94
+ };
95
+ walk(root, "", real(root) ?? root);
96
+ return found;
97
+ }