@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 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
+ }