@jxsuite/parser 0.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/package.json +36 -0
- package/src/MarkdownCollection.class.json +127 -0
- package/src/MarkdownFile.class.json +94 -0
- package/src/md.js +372 -0
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jxsuite/parser",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Jx markdown parser and external class integration",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"src/"
|
|
8
|
+
],
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/md.js",
|
|
12
|
+
"./MarkdownFile.class.json": "./src/MarkdownFile.class.json",
|
|
13
|
+
"./MarkdownCollection.class.json": "./src/MarkdownCollection.class.json"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "bun test",
|
|
17
|
+
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"glob": "^13.0.6",
|
|
21
|
+
"mdast-util-to-string": "^4.0.0",
|
|
22
|
+
"rehype-stringify": "^10.0.1",
|
|
23
|
+
"remark-directive": "^4.0.0",
|
|
24
|
+
"remark-frontmatter": "^5.0.0",
|
|
25
|
+
"remark-gfm": "^4.0.1",
|
|
26
|
+
"remark-parse": "^11.0.0",
|
|
27
|
+
"remark-parse-frontmatter": "^1.0.3",
|
|
28
|
+
"remark-rehype": "^11.1.2",
|
|
29
|
+
"unified": "^11.0.5",
|
|
30
|
+
"unist-util-visit": "^5.1.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@happy-dom/global-registrator": "^20.9.0",
|
|
34
|
+
"@jxsuite/runtime": "workspace:*"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://jxsuite.com/schemas/MarkdownCollection.class.json",
|
|
4
|
+
"title": "MarkdownCollection",
|
|
5
|
+
"description": "Load and parse a collection of Markdown files",
|
|
6
|
+
"$prototype": "Class",
|
|
7
|
+
"extends": "Object",
|
|
8
|
+
"$implementation": "./md.js",
|
|
9
|
+
|
|
10
|
+
"$defs": {
|
|
11
|
+
"parameters": {
|
|
12
|
+
"src": {
|
|
13
|
+
"identifier": "src",
|
|
14
|
+
"type": { "type": "string" },
|
|
15
|
+
"description": "Glob pattern for markdown files",
|
|
16
|
+
"examples": ["./content/posts/*.md"]
|
|
17
|
+
},
|
|
18
|
+
"sortBy": {
|
|
19
|
+
"identifier": "sortBy",
|
|
20
|
+
"type": { "type": "string", "default": "frontmatter.date" },
|
|
21
|
+
"description": "Dot-path to sort key"
|
|
22
|
+
},
|
|
23
|
+
"sortOrder": {
|
|
24
|
+
"identifier": "sortOrder",
|
|
25
|
+
"type": { "type": "string", "enum": ["asc", "desc"], "default": "desc" },
|
|
26
|
+
"description": "Sort direction"
|
|
27
|
+
},
|
|
28
|
+
"limit": {
|
|
29
|
+
"identifier": "limit",
|
|
30
|
+
"type": { "type": "integer", "minimum": 1 },
|
|
31
|
+
"description": "Maximum number of results"
|
|
32
|
+
},
|
|
33
|
+
"directives": {
|
|
34
|
+
"identifier": "directives",
|
|
35
|
+
"type": { "type": "boolean", "default": false },
|
|
36
|
+
"description": "Enable remark-directive plugin"
|
|
37
|
+
},
|
|
38
|
+
"basePath": {
|
|
39
|
+
"identifier": "basePath",
|
|
40
|
+
"type": { "type": "string" },
|
|
41
|
+
"description": "Base path for resolving src glob"
|
|
42
|
+
},
|
|
43
|
+
"itemSchema": {
|
|
44
|
+
"identifier": "itemSchema",
|
|
45
|
+
"type": { "type": "object" },
|
|
46
|
+
"format": "json-schema",
|
|
47
|
+
"description": "JSON Schema describing frontmatter fields for items in this collection"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
"returnTypes": {
|
|
52
|
+
"MarkdownFileResult": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"description": "A single parsed markdown file in the collection",
|
|
55
|
+
"properties": {
|
|
56
|
+
"slug": { "type": "string", "description": "Filename without extension" },
|
|
57
|
+
"path": { "type": "string", "description": "Absolute file path" },
|
|
58
|
+
"frontmatter": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"description": "Shape defined by itemSchema parameter"
|
|
61
|
+
},
|
|
62
|
+
"$body": { "type": "string", "description": "Rendered HTML string" },
|
|
63
|
+
"$excerpt": { "type": "string", "description": "First paragraph as plain text" },
|
|
64
|
+
"$toc": {
|
|
65
|
+
"type": "array",
|
|
66
|
+
"description": "Table of contents entries",
|
|
67
|
+
"items": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": {
|
|
70
|
+
"depth": { "type": "integer" },
|
|
71
|
+
"text": { "type": "string" },
|
|
72
|
+
"id": { "type": "string" }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"$readingTime": { "type": "integer", "description": "Estimated reading time in minutes" },
|
|
77
|
+
"$wordCount": { "type": "integer", "description": "Total word count" }
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"MarkdownCollection": {
|
|
81
|
+
"type": "array",
|
|
82
|
+
"description": "Sorted, filtered array of MarkdownFileResult objects",
|
|
83
|
+
"items": { "$ref": "#/$defs/returnTypes/MarkdownFileResult" }
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
"fields": {
|
|
88
|
+
"config": {
|
|
89
|
+
"role": "field",
|
|
90
|
+
"access": "private",
|
|
91
|
+
"scope": "instance",
|
|
92
|
+
"identifier": "config",
|
|
93
|
+
"type": { "type": "object" },
|
|
94
|
+
"description": "Raw configuration object passed to the constructor"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
"constructor": {
|
|
99
|
+
"role": "constructor",
|
|
100
|
+
"$prototype": "Function",
|
|
101
|
+
"parameters": [
|
|
102
|
+
{ "$ref": "#/$defs/parameters/src" },
|
|
103
|
+
{ "$ref": "#/$defs/parameters/sortBy" },
|
|
104
|
+
{ "$ref": "#/$defs/parameters/sortOrder" },
|
|
105
|
+
{ "$ref": "#/$defs/parameters/limit" },
|
|
106
|
+
{ "$ref": "#/$defs/parameters/directives" },
|
|
107
|
+
{ "$ref": "#/$defs/parameters/basePath" },
|
|
108
|
+
{ "$ref": "#/$defs/parameters/itemSchema" }
|
|
109
|
+
],
|
|
110
|
+
"body": ["this.config = config;"],
|
|
111
|
+
"description": "Stores the configuration for deferred resolution"
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
"methods": {
|
|
115
|
+
"resolve": {
|
|
116
|
+
"role": "method",
|
|
117
|
+
"$prototype": "Function",
|
|
118
|
+
"access": "public",
|
|
119
|
+
"scope": "instance",
|
|
120
|
+
"identifier": "resolve",
|
|
121
|
+
"parameters": [],
|
|
122
|
+
"returnType": { "$ref": "#/$defs/returnTypes/MarkdownCollection" },
|
|
123
|
+
"description": "Glob files, parse each, sort, filter, and limit. Returns array of MarkdownFileResult."
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://jxsuite.com/schemas/MarkdownFile.class.json",
|
|
4
|
+
"title": "MarkdownFile",
|
|
5
|
+
"description": "Load and parse a single Markdown file",
|
|
6
|
+
"$prototype": "Class",
|
|
7
|
+
"extends": "Object",
|
|
8
|
+
"$implementation": "./md.js",
|
|
9
|
+
|
|
10
|
+
"$defs": {
|
|
11
|
+
"parameters": {
|
|
12
|
+
"src": {
|
|
13
|
+
"identifier": "src",
|
|
14
|
+
"type": { "type": "string" },
|
|
15
|
+
"description": "Path to markdown file",
|
|
16
|
+
"examples": ["./content/about.md"]
|
|
17
|
+
},
|
|
18
|
+
"directives": {
|
|
19
|
+
"identifier": "directives",
|
|
20
|
+
"type": { "type": "boolean", "default": false },
|
|
21
|
+
"description": "Enable remark-directive plugin"
|
|
22
|
+
},
|
|
23
|
+
"basePath": {
|
|
24
|
+
"identifier": "basePath",
|
|
25
|
+
"type": { "type": "string" },
|
|
26
|
+
"description": "Base path for resolving src"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
"returnTypes": {
|
|
31
|
+
"MarkdownFileResult": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"description": "Parsed markdown file result",
|
|
34
|
+
"properties": {
|
|
35
|
+
"slug": { "type": "string", "description": "Filename without extension" },
|
|
36
|
+
"path": { "type": "string", "description": "Absolute file path" },
|
|
37
|
+
"frontmatter": { "type": "object", "description": "YAML frontmatter key-value pairs" },
|
|
38
|
+
"$body": { "type": "string", "description": "Rendered HTML string" },
|
|
39
|
+
"$excerpt": { "type": "string", "description": "First paragraph as plain text" },
|
|
40
|
+
"$toc": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"description": "Table of contents entries",
|
|
43
|
+
"items": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"depth": { "type": "integer" },
|
|
47
|
+
"text": { "type": "string" },
|
|
48
|
+
"id": { "type": "string" }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"$readingTime": { "type": "integer", "description": "Estimated reading time in minutes" },
|
|
53
|
+
"$wordCount": { "type": "integer", "description": "Total word count" }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
"fields": {
|
|
59
|
+
"config": {
|
|
60
|
+
"role": "field",
|
|
61
|
+
"access": "private",
|
|
62
|
+
"scope": "instance",
|
|
63
|
+
"identifier": "config",
|
|
64
|
+
"type": { "type": "object" },
|
|
65
|
+
"description": "Raw configuration object passed to the constructor"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
"constructor": {
|
|
70
|
+
"role": "constructor",
|
|
71
|
+
"$prototype": "Function",
|
|
72
|
+
"parameters": [
|
|
73
|
+
{ "$ref": "#/$defs/parameters/src" },
|
|
74
|
+
{ "$ref": "#/$defs/parameters/directives" },
|
|
75
|
+
{ "$ref": "#/$defs/parameters/basePath" }
|
|
76
|
+
],
|
|
77
|
+
"body": ["this.config = config;"],
|
|
78
|
+
"description": "Stores the configuration for deferred resolution"
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
"methods": {
|
|
82
|
+
"resolve": {
|
|
83
|
+
"role": "method",
|
|
84
|
+
"$prototype": "Function",
|
|
85
|
+
"access": "public",
|
|
86
|
+
"scope": "instance",
|
|
87
|
+
"identifier": "resolve",
|
|
88
|
+
"parameters": [],
|
|
89
|
+
"returnType": { "$ref": "#/$defs/returnTypes/MarkdownFileResult" },
|
|
90
|
+
"description": "Parse and resolve the markdown file into a MarkdownFileResult"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/md.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jxsuite/md — Markdown integration for Jx
|
|
3
|
+
*
|
|
4
|
+
* Provides three exports:
|
|
5
|
+
* - MarkdownFile — Parse a single markdown file (external class for $prototype)
|
|
6
|
+
* - MarkdownCollection — Parse a glob of markdown files as a content collection
|
|
7
|
+
* - MarkdownDirective — Remark plugin mapping directives to custom element tags
|
|
8
|
+
*
|
|
9
|
+
* Built on the unified/remark/rehype ecosystem. No framework dependency.
|
|
10
|
+
*
|
|
11
|
+
* @module @jxsuite/md
|
|
12
|
+
* @license MIT
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { unified } from "unified";
|
|
16
|
+
import remarkParse from "remark-parse";
|
|
17
|
+
import remarkFrontmatter from "remark-frontmatter";
|
|
18
|
+
import remarkParseFrontmatter from "remark-parse-frontmatter";
|
|
19
|
+
import remarkGfm from "remark-gfm";
|
|
20
|
+
import remarkDirective from "remark-directive";
|
|
21
|
+
import remarkRehype from "remark-rehype";
|
|
22
|
+
import rehypeStringify from "rehype-stringify";
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { basename, extname, resolve as resolvePath } from "node:path";
|
|
25
|
+
import { globSync } from "glob";
|
|
26
|
+
|
|
27
|
+
// ─── Tree utilities (inline to avoid Bun ESM resolution issues with unist-util-*) ──
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Walk an AST tree, calling visitor for nodes matching the given type.
|
|
31
|
+
*
|
|
32
|
+
* @param {any} tree
|
|
33
|
+
* @param {string | function} typeOrVisitor
|
|
34
|
+
* @param {function} [maybeVisitor]
|
|
35
|
+
*/
|
|
36
|
+
function visit(tree, typeOrVisitor, maybeVisitor) {
|
|
37
|
+
const type = typeof typeOrVisitor === "string" ? typeOrVisitor : null;
|
|
38
|
+
const visitor = type ? maybeVisitor : typeOrVisitor;
|
|
39
|
+
|
|
40
|
+
function walk(/** @type {any} */ node) {
|
|
41
|
+
if (!node || typeof node !== "object") return;
|
|
42
|
+
if (!type || node.type === type) /** @type {Function} */ (visitor)(node);
|
|
43
|
+
if (Array.isArray(node.children)) {
|
|
44
|
+
for (const child of node.children) walk(child);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
walk(tree);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Serialize an mdast tree to plain text.
|
|
52
|
+
*
|
|
53
|
+
* @param {any} node
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function mdastToString(node) {
|
|
57
|
+
if (!node) return "";
|
|
58
|
+
if (typeof node === "string") return node;
|
|
59
|
+
if (node.value) return node.value;
|
|
60
|
+
if (Array.isArray(node.children)) return node.children.map(mdastToString).join("");
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Estimate reading time based on word count (~200 wpm average).
|
|
68
|
+
*
|
|
69
|
+
* @param {string} text
|
|
70
|
+
* @returns {number} Minutes (rounded up, minimum 1)
|
|
71
|
+
*/
|
|
72
|
+
function readingTime(text) {
|
|
73
|
+
const words = text.split(/\s+/).filter(Boolean).length;
|
|
74
|
+
return Math.max(1, Math.ceil(words / 200));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Extract table of contents entries from an mdast tree.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} tree - Mdast AST
|
|
81
|
+
* @returns {{ depth: number; text: string; id: string }[]}
|
|
82
|
+
*/
|
|
83
|
+
function extractToc(tree) {
|
|
84
|
+
/** @type {{ depth: number; text: string; id: string }[]} */
|
|
85
|
+
const entries = [];
|
|
86
|
+
visit(tree, "heading", (/** @type {any} */ node) => {
|
|
87
|
+
const text = mdastToString(node);
|
|
88
|
+
const id = text
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/[^\w\s-]/g, "")
|
|
91
|
+
.replace(/\s+/g, "-")
|
|
92
|
+
.replace(/-+/g, "-")
|
|
93
|
+
.replace(/^-|-$/g, "");
|
|
94
|
+
entries.push({ depth: node.depth, text, id });
|
|
95
|
+
});
|
|
96
|
+
return entries;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Extract first paragraph as HTML excerpt from an mdast tree.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} tree - Mdast AST
|
|
103
|
+
* @returns {Promise<string>} HTML string of first paragraph, or empty string
|
|
104
|
+
*/
|
|
105
|
+
async function extractExcerpt(tree) {
|
|
106
|
+
/** @type {any} */
|
|
107
|
+
let firstParagraph = null;
|
|
108
|
+
visit(tree, "paragraph", (/** @type {any} */ node) => {
|
|
109
|
+
if (!firstParagraph) firstParagraph = node;
|
|
110
|
+
});
|
|
111
|
+
if (!firstParagraph) return "";
|
|
112
|
+
const excerptTree = { type: "root", children: [firstParagraph] };
|
|
113
|
+
const result = await unified()
|
|
114
|
+
.use(remarkRehype)
|
|
115
|
+
.use(rehypeStringify)
|
|
116
|
+
.stringify(
|
|
117
|
+
/** @type {any} */ (await unified().use(remarkRehype).run(/** @type {any} */ (excerptTree))),
|
|
118
|
+
);
|
|
119
|
+
return String(result);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build the unified processing pipeline with standard plugins.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} config
|
|
126
|
+
* @param {any} [config.directiveOptions]
|
|
127
|
+
* @param {any[]} [config.remarkPlugins]
|
|
128
|
+
* @param {any[]} [config.rehypePlugins]
|
|
129
|
+
* @returns {any} Unified processor
|
|
130
|
+
*/
|
|
131
|
+
function buildProcessor(config = {}) {
|
|
132
|
+
let processor = unified()
|
|
133
|
+
.use(remarkParse)
|
|
134
|
+
.use(remarkFrontmatter, ["yaml"])
|
|
135
|
+
.use(remarkParseFrontmatter)
|
|
136
|
+
.use(remarkGfm)
|
|
137
|
+
.use(remarkDirective)
|
|
138
|
+
.use(/** @type {any} */ (MarkdownDirective), config.directiveOptions ?? {});
|
|
139
|
+
|
|
140
|
+
for (const plugin of config.remarkPlugins ?? []) {
|
|
141
|
+
processor = Array.isArray(plugin) ? processor.use(plugin[0], plugin[1]) : processor.use(plugin);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
processor = processor.use(remarkRehype, { allowDangerousHtml: true });
|
|
145
|
+
|
|
146
|
+
for (const plugin of config.rehypePlugins ?? []) {
|
|
147
|
+
processor = Array.isArray(plugin) ? processor.use(plugin[0], plugin[1]) : processor.use(plugin);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
processor = /** @type {any} */ (
|
|
151
|
+
processor.use(rehypeStringify, /** @type {any} */ ({ allowDangerousHtml: true }))
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return processor;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Process a single markdown source string into a MarkdownFileResult.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} source - Raw markdown string
|
|
161
|
+
* @param {string} filePath - File path (for slug derivation)
|
|
162
|
+
* @param {any} config - Processing options
|
|
163
|
+
* @returns {Promise<object>} MarkdownFileResult
|
|
164
|
+
*/
|
|
165
|
+
async function processMarkdown(source, filePath, config = {}) {
|
|
166
|
+
const processor = buildProcessor(config);
|
|
167
|
+
|
|
168
|
+
const vfile = await processor.process(source);
|
|
169
|
+
const frontmatter = /** @type {any} */ (vfile.data)?.frontmatter ?? {};
|
|
170
|
+
|
|
171
|
+
// Parse a separate tree for TOC/excerpt extraction (without rehype transform)
|
|
172
|
+
const mdProcessor = unified().use(remarkParse).use(remarkFrontmatter, ["yaml"]).use(remarkGfm);
|
|
173
|
+
const tree = mdProcessor.parse(source);
|
|
174
|
+
|
|
175
|
+
const plainText = mdastToString(tree);
|
|
176
|
+
const toc = extractToc(tree);
|
|
177
|
+
const excerpt = await extractExcerpt(tree);
|
|
178
|
+
const slug = basename(filePath, extname(filePath));
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
slug,
|
|
182
|
+
path: filePath,
|
|
183
|
+
frontmatter,
|
|
184
|
+
$body: String(vfile),
|
|
185
|
+
$excerpt: excerpt,
|
|
186
|
+
$toc: toc,
|
|
187
|
+
$readingTime: readingTime(plainText),
|
|
188
|
+
$wordCount: plainText.split(/\s+/).filter(Boolean).length,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Resolve a dot-notation path within an object.
|
|
194
|
+
*
|
|
195
|
+
* @param {any} obj
|
|
196
|
+
* @param {string} path
|
|
197
|
+
* @returns {any}
|
|
198
|
+
*/
|
|
199
|
+
function getNestedValue(obj, path) {
|
|
200
|
+
return path.split(".").reduce((/** @type {any} */ o, k) => o?.[k], obj);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── MarkdownFile ─────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Parse a single markdown file. Satisfies the Jx external class contract ($prototype).
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* { "$prototype": "MarkdownFile", "$src": "@jxsuite/md", "src": "./content/about.md" }
|
|
210
|
+
*/
|
|
211
|
+
export class MarkdownFile {
|
|
212
|
+
/**
|
|
213
|
+
* @param {object} config
|
|
214
|
+
* @param {string} config.src - File path to markdown file
|
|
215
|
+
* @param {any[]} [config.remarkPlugins] Default is `[]`
|
|
216
|
+
* @param {any[]} [config.rehypePlugins] Default is `[]`
|
|
217
|
+
* @param {string} [config.basePath] - Base path for resolving src
|
|
218
|
+
* @param {boolean} [config.directives] - Enable directive support
|
|
219
|
+
*/
|
|
220
|
+
constructor(config) {
|
|
221
|
+
this.config = config;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Parse and resolve the markdown file.
|
|
226
|
+
*
|
|
227
|
+
* @returns {Promise<object>} MarkdownFileResult
|
|
228
|
+
*/
|
|
229
|
+
async resolve() {
|
|
230
|
+
const { src, basePath, ...processorConfig } = this.config;
|
|
231
|
+
const filePath = basePath ? resolvePath(basePath, src) : resolvePath(src);
|
|
232
|
+
const source = readFileSync(filePath, "utf-8");
|
|
233
|
+
return processMarkdown(source, filePath, processorConfig);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── MarkdownCollection ───────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Parse a glob of markdown files into a sorted, filterable array. Satisfies the Jx external class
|
|
241
|
+
* contract ($prototype).
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* { "$prototype": "MarkdownCollection", "$src": "@jxsuite/md", "src": "./posts/*.md" }
|
|
245
|
+
*/
|
|
246
|
+
export class MarkdownCollection {
|
|
247
|
+
/**
|
|
248
|
+
* @param {object} config
|
|
249
|
+
* @param {string} config.src - Glob pattern or directory path
|
|
250
|
+
* @param {string} [config.sortBy] Default is `'frontmatter.date'`
|
|
251
|
+
* @param {string} [config.sortOrder] Default is `'desc'`
|
|
252
|
+
* @param {number} [config.limit]
|
|
253
|
+
* @param {Function} [config.filter] - Filter function
|
|
254
|
+
* @param {any[]} [config.remarkPlugins] Default is `[]`
|
|
255
|
+
* @param {any[]} [config.rehypePlugins] Default is `[]`
|
|
256
|
+
* @param {string} [config.basePath] - Base path for resolving glob
|
|
257
|
+
* @param {boolean} [config.directives] - Enable directive support
|
|
258
|
+
*/
|
|
259
|
+
constructor(config) {
|
|
260
|
+
this.config = config;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Glob files, parse each, sort, filter, and limit.
|
|
265
|
+
*
|
|
266
|
+
* @returns {Promise<object[]>} Array of MarkdownFileResult
|
|
267
|
+
*/
|
|
268
|
+
async resolve() {
|
|
269
|
+
const {
|
|
270
|
+
src,
|
|
271
|
+
sortBy = "frontmatter.date",
|
|
272
|
+
sortOrder = "desc",
|
|
273
|
+
limit,
|
|
274
|
+
filter,
|
|
275
|
+
basePath,
|
|
276
|
+
...processorConfig
|
|
277
|
+
} = this.config;
|
|
278
|
+
|
|
279
|
+
const resolved = basePath ? resolvePath(basePath, src) : src;
|
|
280
|
+
// Normalize to forward slashes — glob requires POSIX paths on all platforms
|
|
281
|
+
const pattern = resolved.split("\\").join("/");
|
|
282
|
+
const files = globSync(pattern, { absolute: true });
|
|
283
|
+
|
|
284
|
+
const results = await Promise.all(
|
|
285
|
+
files.map(async (filePath) => {
|
|
286
|
+
const source = readFileSync(filePath, "utf-8");
|
|
287
|
+
return processMarkdown(source, filePath, processorConfig);
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// Filter
|
|
292
|
+
let filtered = results;
|
|
293
|
+
if (typeof filter === "function") {
|
|
294
|
+
filtered = results.filter(/** @type {any} */ (filter));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Sort
|
|
298
|
+
filtered.sort((a, b) => {
|
|
299
|
+
const aVal = getNestedValue(a, sortBy) ?? "";
|
|
300
|
+
const bVal = getNestedValue(b, sortBy) ?? "";
|
|
301
|
+
if (aVal < bVal) return sortOrder === "asc" ? -1 : 1;
|
|
302
|
+
if (aVal > bVal) return sortOrder === "asc" ? 1 : -1;
|
|
303
|
+
return 0;
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Limit
|
|
307
|
+
if (limit && limit > 0) {
|
|
308
|
+
return filtered.slice(0, limit);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return filtered;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ─── MarkdownDirective ────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Remark plugin: map markdown directives to custom element tags in HTML output.
|
|
319
|
+
*
|
|
320
|
+
* Works with `remark-directive` — must be placed after it in the pipeline. Converts
|
|
321
|
+
* ::directive-name{attrs} → <directive-name attrs> in hast output.
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* unified()
|
|
325
|
+
* .use(remarkParse)
|
|
326
|
+
* .use(remarkDirective)
|
|
327
|
+
* .use(MarkdownDirective, { prefix: "jx-" })
|
|
328
|
+
* .use(remarkRehype)
|
|
329
|
+
* .use(rehypeStringify);
|
|
330
|
+
*
|
|
331
|
+
* @param {object} [options]
|
|
332
|
+
* @param {string} [options.prefix] - Prefix for directives without hyphens. Default is `'jx-'`
|
|
333
|
+
* @param {boolean} [options.passContent] - Pass container content as slot. Default is `true`
|
|
334
|
+
* @param {string[]} [options.allowedNames] - Whitelist of allowed directive names
|
|
335
|
+
* @returns {function} Remark plugin transform function
|
|
336
|
+
*/
|
|
337
|
+
export function MarkdownDirective(options = {}) {
|
|
338
|
+
const { prefix = "jx-", passContent = true, allowedNames } = options;
|
|
339
|
+
|
|
340
|
+
return (/** @type {any} */ tree) => {
|
|
341
|
+
visit(tree, (/** @type {any} */ node) => {
|
|
342
|
+
if (
|
|
343
|
+
node.type === "leafDirective" ||
|
|
344
|
+
node.type === "containerDirective" ||
|
|
345
|
+
node.type === "textDirective"
|
|
346
|
+
) {
|
|
347
|
+
const rawName = node.name;
|
|
348
|
+
|
|
349
|
+
// Check whitelist
|
|
350
|
+
if (allowedNames && !allowedNames.includes(rawName)) return;
|
|
351
|
+
|
|
352
|
+
// Custom element names must contain a hyphen per Web Components spec
|
|
353
|
+
const tagName = rawName.includes("-") ? rawName : `${prefix}${rawName}`;
|
|
354
|
+
|
|
355
|
+
// Set hast properties for remarkRehype
|
|
356
|
+
const data = node.data || (node.data = {});
|
|
357
|
+
data.hName = tagName;
|
|
358
|
+
data.hProperties = { ...node.attributes };
|
|
359
|
+
|
|
360
|
+
// For text directives, preserve label as children
|
|
361
|
+
if (node.type === "textDirective" && node.children?.length > 0) {
|
|
362
|
+
// Children are already part of the mdast node; remarkRehype handles them
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// For container directives, content is already in node.children
|
|
366
|
+
if (node.type === "containerDirective" && !passContent) {
|
|
367
|
+
node.children = [];
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
}
|