@jxsuite/parser 2.0.0 → 3.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/dist/content-loader.js +1482 -671
- package/dist/content.js +183 -30
- package/dist/markdown.js +1271 -742
- package/dist/serialize.js +1974 -1457
- package/package.json +5 -5
- package/src/alerts.ts +22 -7
- package/src/content-data.ts +40 -0
- package/src/content-links.ts +15 -7
- package/src/content-loader.ts +681 -221
- package/src/content-routes.ts +23 -10
- package/src/content-rules.ts +40 -29
- package/src/content.ts +108 -70
- package/src/csv.ts +18 -14
- package/src/dates.ts +18 -13
- package/src/highlight.ts +6 -5
- package/src/inert.ts +7 -5
- package/src/markdown.ts +27 -23
- package/src/md.ts +133 -64
- package/src/serialize.ts +367 -266
- package/src/transpile.ts +182 -152
- package/src/types.ts +43 -11
package/src/dates.ts
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* @docs framework/site/content-collections
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import
|
|
25
|
+
import { isString } from "@jxsuite/schema/guards";
|
|
26
|
+
import type { ContentFieldValue, ContentTypeSchema } from "@jxsuite/schema/types";
|
|
26
27
|
import type { ContentLoaderEntry } from "./types.ts";
|
|
27
28
|
|
|
28
29
|
/** The two JSON Schema `format` values that carry build behavior. */
|
|
@@ -30,7 +31,7 @@ export const DATE_FORMATS = ["date", "date-time"] as const;
|
|
|
30
31
|
export type DateFormat = (typeof DATE_FORMATS)[number];
|
|
31
32
|
|
|
32
33
|
export function isDateFormat(format: unknown): format is DateFormat {
|
|
33
|
-
return
|
|
34
|
+
return DATE_FORMATS.some((candidate) => candidate === format);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/** `YYYY-MM-DD`, with no time part. */
|
|
@@ -58,7 +59,10 @@ function toInstant(d: Date): string {
|
|
|
58
59
|
* Accepts, in order: a `Date` (YAML frontmatter parsers routinely produce one for an unquoted
|
|
59
60
|
* `date: 2025-03-04`), an RFC 3339 string, and a bare `YYYY-MM-DD`. Everything else is refused.
|
|
60
61
|
*/
|
|
61
|
-
export function parseDateValue(
|
|
62
|
+
export function parseDateValue(
|
|
63
|
+
value: ContentFieldValue | undefined,
|
|
64
|
+
format: DateFormat,
|
|
65
|
+
): string | null {
|
|
62
66
|
if (value instanceof Date) {
|
|
63
67
|
return Number.isNaN(value.getTime())
|
|
64
68
|
? null
|
|
@@ -66,7 +70,7 @@ export function parseDateValue(value: unknown, format: DateFormat): string | nul
|
|
|
66
70
|
? toDateOnly(value)
|
|
67
71
|
: toInstant(value);
|
|
68
72
|
}
|
|
69
|
-
if (
|
|
73
|
+
if (!isString(value)) {
|
|
70
74
|
return null;
|
|
71
75
|
}
|
|
72
76
|
const text = value.trim();
|
|
@@ -107,7 +111,7 @@ export function isCoercedDate(value: string): boolean {
|
|
|
107
111
|
export interface DateCoercionWarning {
|
|
108
112
|
entryId: string;
|
|
109
113
|
field: string;
|
|
110
|
-
value:
|
|
114
|
+
value: ContentFieldValue;
|
|
111
115
|
message: string;
|
|
112
116
|
}
|
|
113
117
|
|
|
@@ -128,9 +132,9 @@ export function coerceEntryDates(
|
|
|
128
132
|
return warnings;
|
|
129
133
|
}
|
|
130
134
|
|
|
131
|
-
const dateFields: [string, DateFormat][]
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
const dateFields = Object.entries(properties).flatMap(([field, def]): [string, DateFormat][] =>
|
|
136
|
+
isDateFormat(def.format) ? [[field, def.format]] : [],
|
|
137
|
+
);
|
|
134
138
|
if (dateFields.length === 0) {
|
|
135
139
|
return warnings;
|
|
136
140
|
}
|
|
@@ -174,11 +178,12 @@ export function coerceEntryDates(
|
|
|
174
178
|
* compares false against everything. Normalized RFC 3339 strings compare chronologically as text,
|
|
175
179
|
* so the fix is to notice when both sides are dates and stay in string space.
|
|
176
180
|
*/
|
|
177
|
-
export function parseComparable(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
181
|
+
export function parseComparable(
|
|
182
|
+
a: ContentFieldValue | undefined,
|
|
183
|
+
b: ContentFieldValue | undefined,
|
|
184
|
+
): [number, number] | [string, string] {
|
|
185
|
+
if (isString(a) && isCoercedDate(a) && isString(b) && isCoercedDate(b)) {
|
|
186
|
+
return [a, b];
|
|
182
187
|
}
|
|
183
188
|
return [Number(a), Number(b)];
|
|
184
189
|
}
|
package/src/highlight.ts
CHANGED
|
@@ -39,6 +39,7 @@ import toml from "@shikijs/langs/toml";
|
|
|
39
39
|
import typescript from "@shikijs/langs/typescript";
|
|
40
40
|
import xml from "@shikijs/langs/xml";
|
|
41
41
|
import yaml from "@shikijs/langs/yaml";
|
|
42
|
+
import { isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
42
43
|
import type { JxElement } from "@jxsuite/schema/types";
|
|
43
44
|
import type { HighlighterCore } from "shiki/core";
|
|
44
45
|
import { caddyfile } from "./grammars/caddyfile.ts";
|
|
@@ -106,7 +107,7 @@ export function highlightFence(code: string, info: string): (JxElement | string)
|
|
|
106
107
|
for (const token of line) {
|
|
107
108
|
const span: JxElement = { tagName: "span", textContent: token.content };
|
|
108
109
|
if (token.htmlStyle && Object.keys(token.htmlStyle).length > 0) {
|
|
109
|
-
span.style = token.htmlStyle
|
|
110
|
+
span.style = token.htmlStyle;
|
|
110
111
|
}
|
|
111
112
|
out.push(span);
|
|
112
113
|
}
|
|
@@ -122,17 +123,17 @@ export function highlightFence(code: string, info: string): (JxElement | string)
|
|
|
122
123
|
*/
|
|
123
124
|
export function highlightCodeBlocks(nodes: (JxElement | string)[]): void {
|
|
124
125
|
for (const node of nodes) {
|
|
125
|
-
if (!node
|
|
126
|
+
if (!isNonNullObject(node)) {
|
|
126
127
|
continue;
|
|
127
128
|
}
|
|
128
129
|
if (node.tagName === "pre" && Array.isArray(node.children)) {
|
|
129
130
|
for (const child of node.children) {
|
|
130
|
-
if (!child ||
|
|
131
|
+
if (!isNonNullObject(child) || child.tagName !== "code") {
|
|
131
132
|
continue;
|
|
132
133
|
}
|
|
133
134
|
const lang = /^language-(\S+)/.exec(child.className ?? "")?.[1];
|
|
134
135
|
const code = child.textContent;
|
|
135
|
-
if (!lang ||
|
|
136
|
+
if (!lang || !isString(code) || code.length === 0) {
|
|
136
137
|
continue;
|
|
137
138
|
}
|
|
138
139
|
const spans = highlightFence(code, lang);
|
|
@@ -146,7 +147,7 @@ export function highlightCodeBlocks(nodes: (JxElement | string)[]): void {
|
|
|
146
147
|
continue;
|
|
147
148
|
}
|
|
148
149
|
if (Array.isArray(node.children)) {
|
|
149
|
-
highlightCodeBlocks(node.children
|
|
150
|
+
highlightCodeBlocks(node.children);
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
153
|
}
|
package/src/inert.ts
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* @docs framework/site/jx-markdown
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
+
import { isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
39
40
|
import type { JxElement } from "@jxsuite/schema/types";
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -68,7 +69,7 @@ export function neutralizeTemplateAttribute(value: string): string {
|
|
|
68
69
|
*/
|
|
69
70
|
export function makeTemplatesInert(nodes: ContentNodes): void {
|
|
70
71
|
for (const [i, node] of nodes.entries()) {
|
|
71
|
-
if (
|
|
72
|
+
if (isString(node)) {
|
|
72
73
|
if (node.includes("${")) {
|
|
73
74
|
nodes[i] = { innerHTML: escapeTemplateText(node), tagName: "span" };
|
|
74
75
|
}
|
|
@@ -78,10 +79,11 @@ export function makeTemplatesInert(nodes: ContentNodes): void {
|
|
|
78
79
|
makeTemplatesInert(node);
|
|
79
80
|
continue;
|
|
80
81
|
}
|
|
81
|
-
|
|
82
|
+
// A hole in a hand-built tree (`null`) is skipped, not walked into.
|
|
83
|
+
if (!isNonNullObject(node)) {
|
|
82
84
|
continue;
|
|
83
85
|
}
|
|
84
|
-
if (
|
|
86
|
+
if (isString(node.textContent) && node.textContent.includes("${")) {
|
|
85
87
|
node.innerHTML = escapeTemplateText(node.textContent);
|
|
86
88
|
delete node.textContent;
|
|
87
89
|
}
|
|
@@ -89,13 +91,13 @@ export function makeTemplatesInert(nodes: ContentNodes): void {
|
|
|
89
91
|
if (attributes) {
|
|
90
92
|
for (const name of PROSE_ATTRIBUTES) {
|
|
91
93
|
const value = attributes[name];
|
|
92
|
-
if (
|
|
94
|
+
if (isString(value) && value.includes("${")) {
|
|
93
95
|
attributes[name] = neutralizeTemplateAttribute(value);
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
if (Array.isArray(node.children)) {
|
|
98
|
-
makeTemplatesInert(node.children
|
|
100
|
+
makeTemplatesInert(node.children);
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
}
|
package/src/markdown.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { compileExclude } from "./content-rules.ts";
|
|
|
19
19
|
import { transpileJxMarkdown } from "./transpile.ts";
|
|
20
20
|
import { serializeJxMarkdown } from "./serialize.ts";
|
|
21
21
|
import type { SerializeOptions } from "./serialize.ts";
|
|
22
|
-
import type { JxDocument } from "@jxsuite/schema/types";
|
|
22
|
+
import type { JsonValue, JxDocument } from "@jxsuite/schema/types";
|
|
23
23
|
import type { ContentLoaderEntry, MarkdownFileResult } from "./types.ts";
|
|
24
24
|
|
|
25
25
|
export interface MarkdownLoadOptions {
|
|
@@ -37,9 +37,16 @@ export interface MarkdownLoadOptions {
|
|
|
37
37
|
* The content type's `alerts` option: alert type → custom element, or `false` to leave GitHub
|
|
38
38
|
* alerts (`> [!NOTE]`) as plain blockquotes. Absent renders the built-in callout markup.
|
|
39
39
|
*/
|
|
40
|
-
alerts?:
|
|
40
|
+
alerts?: JsonValue;
|
|
41
41
|
/** Told of each `[!type]` marker whose type is not enabled (it stays a blockquote). */
|
|
42
42
|
onUnknownAlert?: (type: string) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Read the frontmatter and leave the body unparsed: the entry comes back without `$children` and
|
|
45
|
+
* without the `_meta` fields read from the body (`excerpt`, `toc`, `readingTime`, `wordCount`).
|
|
46
|
+
* The content loader asks for this when a host loads a collection lazily, and loads the file
|
|
47
|
+
* again without it once the entry is actually returned (specs/extensions.md §8).
|
|
48
|
+
*/
|
|
49
|
+
deferBody?: boolean;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
/** Options for {@link Markdown.discover}. */
|
|
@@ -105,18 +112,9 @@ export class Markdown {
|
|
|
105
112
|
path: string,
|
|
106
113
|
options: MarkdownLoadOptions = {},
|
|
107
114
|
): Promise<ContentLoaderEntry[]> {
|
|
108
|
-
const { processMarkdown } = await import("./md.ts");
|
|
115
|
+
const { processMarkdown, readFrontmatter } = await import("./md.ts");
|
|
109
116
|
const { readFileSync, statSync } = await import("node:fs");
|
|
110
117
|
const source = readFileSync(path, "utf8");
|
|
111
|
-
const result = processMarkdown(source, path, {
|
|
112
|
-
...(options.directives !== undefined && { directives: options.directives }),
|
|
113
|
-
...(options.directiveOptions !== undefined && {
|
|
114
|
-
directiveOptions: options.directiveOptions,
|
|
115
|
-
}),
|
|
116
|
-
...(options.sourceRoot !== undefined && { sourceRoot: options.sourceRoot }),
|
|
117
|
-
...(options.alerts !== undefined && { alerts: options.alerts }),
|
|
118
|
-
...(options.onUnknownAlert !== undefined && { onUnknownAlert: options.onUnknownAlert }),
|
|
119
|
-
});
|
|
120
118
|
const _meta: ContentLoaderEntry["_meta"] = {};
|
|
121
119
|
/*
|
|
122
120
|
* The modification time is the only date a file always has. A feed falls back to it when the
|
|
@@ -130,18 +128,24 @@ export class Markdown {
|
|
|
130
128
|
} catch {
|
|
131
129
|
// A format class may be handed content that is not on disk; an absent mtime is not an error.
|
|
132
130
|
}
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (result.$toc != null) {
|
|
137
|
-
_meta.toc = result.$toc;
|
|
138
|
-
}
|
|
139
|
-
if (result.$readingTime != null) {
|
|
140
|
-
_meta.readingTime = result.$readingTime;
|
|
141
|
-
}
|
|
142
|
-
if (result.$wordCount != null) {
|
|
143
|
-
_meta.wordCount = result.$wordCount;
|
|
131
|
+
if (options.deferBody) {
|
|
132
|
+
const { frontmatter, slug } = readFrontmatter(source, path, options.sourceRoot);
|
|
133
|
+
return [{ _meta, body: source, data: frontmatter, id: slug }];
|
|
144
134
|
}
|
|
135
|
+
const result = processMarkdown(source, path, {
|
|
136
|
+
...(options.directives !== undefined && { directives: options.directives }),
|
|
137
|
+
...(options.directiveOptions !== undefined && {
|
|
138
|
+
directiveOptions: options.directiveOptions,
|
|
139
|
+
}),
|
|
140
|
+
...(options.sourceRoot !== undefined && { sourceRoot: options.sourceRoot }),
|
|
141
|
+
...(options.alerts !== undefined && { alerts: options.alerts }),
|
|
142
|
+
...(options.onUnknownAlert !== undefined && { onUnknownAlert: options.onUnknownAlert }),
|
|
143
|
+
});
|
|
144
|
+
// `processMarkdown` always derives all four, so every entry carries them.
|
|
145
|
+
_meta.excerpt = result.$excerpt;
|
|
146
|
+
_meta.toc = result.$toc;
|
|
147
|
+
_meta.readingTime = result.$readingTime;
|
|
148
|
+
_meta.wordCount = result.$wordCount;
|
|
145
149
|
return [
|
|
146
150
|
{
|
|
147
151
|
$children: result.$children,
|
package/src/md.ts
CHANGED
|
@@ -20,46 +20,42 @@ import remarkGfm from "remark-gfm";
|
|
|
20
20
|
import remarkDirective from "remark-directive";
|
|
21
21
|
import { readFileSync } from "node:fs";
|
|
22
22
|
import { DEFAULT_FORMAT_LOCALE } from "@jxsuite/schema/intl";
|
|
23
|
-
import { basename, extname, relative, resolve
|
|
23
|
+
import { basename, extname, relative, resolve } from "node:path";
|
|
24
24
|
import { globSync } from "glob";
|
|
25
25
|
import { normalizeAlerts, transformAlerts } from "./alerts.ts";
|
|
26
26
|
import { assignHeadingIds, mdastNodeToJx } from "./transpile.ts";
|
|
27
27
|
import { highlightCodeBlocks } from "./highlight.ts";
|
|
28
28
|
import { makeTemplatesInert } from "./inert.ts";
|
|
29
|
-
import type {
|
|
30
|
-
|
|
29
|
+
import type {
|
|
30
|
+
MarkdownFileResult,
|
|
31
|
+
MdastNode,
|
|
32
|
+
UnifiedFile,
|
|
33
|
+
UnifiedPlugin,
|
|
34
|
+
UnifiedProcessor,
|
|
35
|
+
} from "./types.ts";
|
|
36
|
+
import { isFunction } from "@jxsuite/schema/guards";
|
|
37
|
+
import type { ContentFieldValue, JsonObject, JsonValue, JxElement } from "@jxsuite/schema/types";
|
|
31
38
|
|
|
32
39
|
// ─── Tree utilities (inline to avoid Bun ESM resolution issues with unist-util-*) ──
|
|
33
40
|
|
|
34
41
|
/**
|
|
35
|
-
*
|
|
42
|
+
* Find the first node of a type in an AST tree, in document (depth-first, pre-order) order.
|
|
36
43
|
*
|
|
37
|
-
* @param {
|
|
38
|
-
* @param {string
|
|
39
|
-
* @
|
|
44
|
+
* @param {MdastNode} node
|
|
45
|
+
* @param {string} type
|
|
46
|
+
* @returns {MdastNode | null}
|
|
40
47
|
*/
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
function walk(node: MdastNode) {
|
|
50
|
-
if (!node || typeof node !== "object") {
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
if (!type || node.type === type) {
|
|
54
|
-
(visitor as (node: MdastNode) => void)(node);
|
|
55
|
-
}
|
|
56
|
-
if (Array.isArray(node.children)) {
|
|
57
|
-
for (const child of node.children) {
|
|
58
|
-
walk(child);
|
|
59
|
-
}
|
|
48
|
+
function findFirst(node: MdastNode, type: string): MdastNode | null {
|
|
49
|
+
if (node.type === type) {
|
|
50
|
+
return node;
|
|
51
|
+
}
|
|
52
|
+
for (const child of node.children ?? []) {
|
|
53
|
+
const found = findFirst(child, type);
|
|
54
|
+
if (found) {
|
|
55
|
+
return found;
|
|
60
56
|
}
|
|
61
57
|
}
|
|
62
|
-
|
|
58
|
+
return null;
|
|
63
59
|
}
|
|
64
60
|
|
|
65
61
|
/**
|
|
@@ -69,12 +65,6 @@ function visit(
|
|
|
69
65
|
* @returns {string}
|
|
70
66
|
*/
|
|
71
67
|
function mdastToString(node: MdastNode): string {
|
|
72
|
-
if (!node) {
|
|
73
|
-
return "";
|
|
74
|
-
}
|
|
75
|
-
if (typeof node === "string") {
|
|
76
|
-
return node;
|
|
77
|
-
}
|
|
78
68
|
if (node.value) {
|
|
79
69
|
return node.value;
|
|
80
70
|
}
|
|
@@ -86,6 +76,15 @@ function mdastToString(node: MdastNode): string {
|
|
|
86
76
|
|
|
87
77
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
88
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The unified processor chain, through the hand-written surface `UnifiedProcessor` describes.
|
|
81
|
+
*
|
|
82
|
+
* SAFETY: `unified()` returns a remark-compatible Processor whose `use`, `parse`, `runSync` and
|
|
83
|
+
* `stringify` are the four members `UnifiedProcessor` declares; the assertion only states that
|
|
84
|
+
* surface for a type-aware linter that resolves the package's ESM-only types as `any`.
|
|
85
|
+
*/
|
|
86
|
+
const createProcessor = unified as () => UnifiedProcessor;
|
|
87
|
+
|
|
89
88
|
/** Average reading speed. See {@link readingTime} for why this is one number and not a table. */
|
|
90
89
|
const WORDS_PER_MINUTE = 200;
|
|
91
90
|
|
|
@@ -151,12 +150,7 @@ function readingTime(text: string) {
|
|
|
151
150
|
* @returns {string} Plain text of first paragraph, or empty string
|
|
152
151
|
*/
|
|
153
152
|
function extractExcerpt(tree: MdastNode) {
|
|
154
|
-
|
|
155
|
-
visit(tree, "paragraph", (node: MdastNode) => {
|
|
156
|
-
if (!firstParagraph) {
|
|
157
|
-
firstParagraph = node;
|
|
158
|
-
}
|
|
159
|
-
});
|
|
153
|
+
const firstParagraph = findFirst(tree, "paragraph");
|
|
160
154
|
if (!firstParagraph) {
|
|
161
155
|
return "";
|
|
162
156
|
}
|
|
@@ -191,6 +185,64 @@ function deriveSlug(filePath: string, sourceRoot?: string): string {
|
|
|
191
185
|
return basename(filePath, extname(filePath));
|
|
192
186
|
}
|
|
193
187
|
|
|
188
|
+
/** A frontmatter fence line: `---`, optionally followed by spaces or tabs (micromark's rule). */
|
|
189
|
+
const FENCE_LINE = /^---[ \t]*\r?$/;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Where a leading frontmatter block ends: the index just past its closing fence line, or 0 when the
|
|
193
|
+
* source does not open with one. Only the cut is decided here; whether the block IS frontmatter is
|
|
194
|
+
* still decided by the same remark plugins {@link processMarkdown} runs.
|
|
195
|
+
*/
|
|
196
|
+
function frontmatterEnd(source: string): number {
|
|
197
|
+
const firstBreak = source.indexOf("\n");
|
|
198
|
+
if (firstBreak === -1 || !FENCE_LINE.test(source.slice(0, firstBreak))) {
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
let start = firstBreak + 1;
|
|
202
|
+
while (start < source.length) {
|
|
203
|
+
const lineEnd = source.indexOf("\n", start);
|
|
204
|
+
const end = lineEnd === -1 ? source.length : lineEnd;
|
|
205
|
+
if (FENCE_LINE.test(source.slice(start, end))) {
|
|
206
|
+
return end;
|
|
207
|
+
}
|
|
208
|
+
start = end + 1;
|
|
209
|
+
}
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The parsed frontmatter and slug of a markdown file, without parsing its body.
|
|
215
|
+
*
|
|
216
|
+
* A collection's queries, routes, ids and `where` rules read frontmatter alone, and the body is
|
|
217
|
+
* where the cost is: one knowledge base spent three of its four load seconds highlighting code
|
|
218
|
+
* blocks in articles no request had asked for. The block is parsed by the same plugins
|
|
219
|
+
* {@link processMarkdown} uses, cut off after its closing fence, so the two always agree on what the
|
|
220
|
+
* frontmatter is (specs/parser.md).
|
|
221
|
+
*
|
|
222
|
+
* @param {string} source - Raw markdown string
|
|
223
|
+
* @param {string} filePath - File path (for slug derivation)
|
|
224
|
+
* @param {string} [sourceRoot] - Content-source root; files below it get path-based slugs
|
|
225
|
+
* @returns {{ frontmatter: JsonObject; slug: string }}
|
|
226
|
+
*/
|
|
227
|
+
export function readFrontmatter(source: string, filePath: string, sourceRoot?: string) {
|
|
228
|
+
const slug = deriveSlug(filePath, sourceRoot);
|
|
229
|
+
const end = frontmatterEnd(source);
|
|
230
|
+
if (end === 0) {
|
|
231
|
+
return { frontmatter: {}, slug };
|
|
232
|
+
}
|
|
233
|
+
/* SAFETY: as in processMarkdown below — the three remark packages are shorthand `declare module`
|
|
234
|
+
entries whose default exports are unified attachers taking optional JSON settings. */
|
|
235
|
+
const processor = createProcessor()
|
|
236
|
+
.use(remarkParse as UnifiedPlugin)
|
|
237
|
+
.use(remarkFrontmatter as UnifiedPlugin, ["yaml"])
|
|
238
|
+
.use(remarkParseFrontmatter as UnifiedPlugin);
|
|
239
|
+
const vfile: UnifiedFile = { data: {} };
|
|
240
|
+
processor.runSync(processor.parse(source.slice(0, end)), vfile);
|
|
241
|
+
/* SAFETY: the same claim processMarkdown makes of the same plugin's output: a mapping parses to
|
|
242
|
+
a JsonObject, and no block gives `{}`. */
|
|
243
|
+
return { frontmatter: (vfile.data.frontmatter ?? {}) as JsonObject, slug };
|
|
244
|
+
}
|
|
245
|
+
|
|
194
246
|
/**
|
|
195
247
|
* Process a single markdown source string into a MarkdownFileResult.
|
|
196
248
|
*
|
|
@@ -215,14 +267,18 @@ export function processMarkdown(
|
|
|
215
267
|
directives?: boolean;
|
|
216
268
|
directiveOptions?: unknown;
|
|
217
269
|
sourceRoot?: string;
|
|
218
|
-
alerts?:
|
|
270
|
+
alerts?: JsonValue;
|
|
219
271
|
onUnknownAlert?: (type: string) => void;
|
|
220
272
|
} = {},
|
|
221
273
|
) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
274
|
+
/* SAFETY: `remark-parse`, `remark-frontmatter` and `remark-parse-frontmatter` are shorthand
|
|
275
|
+
`declare module` entries in the root types.d.ts, so their default imports are `any` here. Each
|
|
276
|
+
package default-exports a unified attacher taking optional JSON settings (remark-frontmatter
|
|
277
|
+
receives the `["yaml"]` preset list), which is the UnifiedPlugin shape. */
|
|
278
|
+
let processor = createProcessor()
|
|
279
|
+
.use(remarkParse as UnifiedPlugin)
|
|
280
|
+
.use(remarkFrontmatter as UnifiedPlugin, ["yaml"])
|
|
281
|
+
.use(remarkParseFrontmatter as UnifiedPlugin)
|
|
226
282
|
.use(remarkGfm);
|
|
227
283
|
|
|
228
284
|
if (config.directives || config.directiveOptions) {
|
|
@@ -230,25 +286,34 @@ export function processMarkdown(
|
|
|
230
286
|
}
|
|
231
287
|
|
|
232
288
|
const tree = processor.parse(source);
|
|
233
|
-
const vfile = { data: {} };
|
|
289
|
+
const vfile: UnifiedFile = { data: {} };
|
|
234
290
|
processor.runSync(tree, vfile);
|
|
235
291
|
|
|
236
292
|
// Callouts first, so the excerpt and word count read the alert's own text, not its `[!NOTE]` marker.
|
|
237
293
|
const alerts = normalizeAlerts(config.alerts);
|
|
238
294
|
if (alerts) {
|
|
239
|
-
transformAlerts(tree
|
|
295
|
+
transformAlerts(tree, alerts, config.onUnknownAlert);
|
|
240
296
|
}
|
|
241
297
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
298
|
+
/* SAFETY: a front-matter block is a YAML mapping by convention, which yaml.parse gives as a
|
|
299
|
+
JsonObject, and no block (or an empty one) gives `{}`. A block that parses to a scalar or a
|
|
300
|
+
list is not a mapping; it is relayed as the result's `frontmatter` unchanged, as it always has
|
|
301
|
+
been, so the claimed object type does not hold for that malformed input. */
|
|
302
|
+
const frontmatter = (vfile.data.frontmatter ?? {}) as JsonObject;
|
|
303
|
+
const plainText = mdastToString(tree);
|
|
246
304
|
const excerpt = extractExcerpt(tree);
|
|
247
305
|
const slug = deriveSlug(filePath, config.sourceRoot);
|
|
248
306
|
|
|
249
|
-
const bodyNodes = tree.children
|
|
307
|
+
const bodyNodes = (tree.children ?? []).filter(
|
|
250
308
|
(n: MdastNode) => n.type !== "yaml" && n.type !== "toml",
|
|
251
|
-
)
|
|
309
|
+
);
|
|
310
|
+
/*
|
|
311
|
+
* SAFETY: `filter(Boolean)` drops the nulls (and empty text), and what remains is an element, a
|
|
312
|
+
* string, or the node array a raw HTML block converts to, kept nested as one element. `$children`
|
|
313
|
+
* is declared flat, and every walk over it handles that nested array: highlightCodeBlocks and
|
|
314
|
+
* assignHeadingIds skip it (it has no `tagName` or `children`), while makeTemplatesInert and the
|
|
315
|
+
* content loader's link and asset passes descend into it as `ContentNodes`.
|
|
316
|
+
*/
|
|
252
317
|
const $children = bodyNodes.map((n: MdastNode) => mdastNodeToJx(n)).filter(Boolean) as (
|
|
253
318
|
| JxElement
|
|
254
319
|
| string
|
|
@@ -280,14 +345,19 @@ export function processMarkdown(
|
|
|
280
345
|
/**
|
|
281
346
|
* Resolve a dot-notation path within an object.
|
|
282
347
|
*
|
|
283
|
-
* @param {
|
|
348
|
+
* @param {MarkdownFileResult} obj
|
|
284
349
|
* @param {string} path
|
|
285
|
-
* @returns {
|
|
350
|
+
* @returns {ContentFieldValue | MarkdownFileResult | undefined}
|
|
286
351
|
*/
|
|
287
|
-
function getNestedValue(obj:
|
|
288
|
-
let current:
|
|
352
|
+
function getNestedValue(obj: MarkdownFileResult, path: string) {
|
|
353
|
+
let current: ContentFieldValue | MarkdownFileResult | undefined = obj;
|
|
289
354
|
for (const k of path.split(".")) {
|
|
290
|
-
|
|
355
|
+
/*
|
|
356
|
+
* SAFETY: `processMarkdown` builds a result out of strings, numbers, node arrays and the JSON
|
|
357
|
+
* frontmatter object, so every key a path steps through holds a content field value, or
|
|
358
|
+
* nothing, which `?.` carries to the end of the path.
|
|
359
|
+
*/
|
|
360
|
+
current = (current as Record<string, ContentFieldValue | undefined> | null | undefined)?.[k];
|
|
291
361
|
}
|
|
292
362
|
return current;
|
|
293
363
|
}
|
|
@@ -359,9 +429,10 @@ export class MarkdownCollection {
|
|
|
359
429
|
...processorConfig
|
|
360
430
|
} = this.config;
|
|
361
431
|
|
|
362
|
-
const resolved = basePath ?
|
|
432
|
+
const resolved = basePath ? resolve(basePath, src) : src;
|
|
363
433
|
// Normalize to forward slashes — glob requires POSIX paths on all platforms
|
|
364
434
|
const pattern = resolved.split("\\").join("/");
|
|
435
|
+
// SAFETY: with `absolute: true` and no `withFileTypes`, `globSync` returns the matched paths as strings.
|
|
365
436
|
const files = (globSync as (p: string, o: { absolute: boolean }) => string[])(pattern, {
|
|
366
437
|
absolute: true,
|
|
367
438
|
});
|
|
@@ -373,16 +444,14 @@ export class MarkdownCollection {
|
|
|
373
444
|
|
|
374
445
|
// Filter
|
|
375
446
|
let filtered = results;
|
|
376
|
-
if (
|
|
377
|
-
filtered = results.filter((r: MarkdownFileResult) =>
|
|
378
|
-
(filter as (r: MarkdownFileResult) => boolean)(r),
|
|
379
|
-
);
|
|
447
|
+
if (isFunction(filter)) {
|
|
448
|
+
filtered = results.filter((r: MarkdownFileResult) => filter(r));
|
|
380
449
|
}
|
|
381
450
|
|
|
382
451
|
// Sort
|
|
383
452
|
filtered.sort((a: MarkdownFileResult, b: MarkdownFileResult) => {
|
|
384
|
-
const aVal = getNestedValue(a, sortBy) ??
|
|
385
|
-
const bVal = getNestedValue(b, sortBy) ??
|
|
453
|
+
const aVal = getNestedValue(a, sortBy) ?? "";
|
|
454
|
+
const bVal = getNestedValue(b, sortBy) ?? "";
|
|
386
455
|
if (aVal < bVal) {
|
|
387
456
|
return sortOrder === "asc" ? -1 : 1;
|
|
388
457
|
}
|