@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.
- package/LICENSE +1 -1
- package/README.md +33 -0
- package/dist/content-loader.js +34611 -62
- package/dist/content.js +119 -3
- package/dist/markdown.js +1990 -481
- package/dist/serialize.js +1889 -1225
- package/package.json +8 -8
- 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/transpile.ts +60 -4
- package/src/types.ts +17 -0
- package/src/walk.ts +97 -0
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
|
-
*
|
|
6
|
-
* carrying both colors as CSS custom properties (--shiki-light /
|
|
7
|
-
* stylesheet decides which one paints, so highlighting follows the
|
|
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: [
|
|
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}
|
|
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,
|
|
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("<", "<")
|
|
52
|
+
.replaceAll(">", ">")
|
|
53
|
+
.replaceAll("${", "${");
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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 (
|
|
179
|
-
slug = slug.slice(0,
|
|
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/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
|
-
*
|
|
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
|
-
*
|
|
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
|
*
|
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
|
+
}
|