@ubean/markdown 0.1.13 → 0.2.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.
@@ -0,0 +1,388 @@
1
+ import { createMarkdownExit } from "markdown-exit";
2
+ //#region src/mdx.ts
3
+ /**
4
+ * P9-20: MDX real compilation.
5
+ *
6
+ * Compiles MDX (Markdown + JSX) files to Vue components using `@mdx-js/mdx`
7
+ * as an optional peer dependency. The compiled output uses ubean's Vue JSX
8
+ * runtime (`@ubean/markdown/jsx-runtime`) which maps JSX calls to Vue's `h()`.
9
+ *
10
+ * Features:
11
+ * - Frontmatter extraction (YAML)
12
+ * - JSX component support in Markdown
13
+ * - Vue component imports
14
+ * - Code highlighting via remark/rehype plugins
15
+ * - Graceful fallback to plain Markdown when `@mdx-js/mdx` is not installed
16
+ *
17
+ * Usage:
18
+ * ```typescript
19
+ * import { compileMdx } from '@ubean/markdown/mdx';
20
+ *
21
+ * const result = await compileMdx(mdxSource, {
22
+ * filePath: 'post.mdx',
23
+ * remarkPlugins: [remarkGfm],
24
+ * });
25
+ * // result.code → JavaScript module string (Vue component)
26
+ * // result.frontmatter → parsed frontmatter
27
+ * ```
28
+ */
29
+ let _mdxCompiler;
30
+ /**
31
+ * Dynamically load `@mdx-js/mdx` (optional peer dependency).
32
+ * Returns null if not installed.
33
+ */
34
+ async function getMdxCompiler() {
35
+ if (_mdxCompiler !== void 0) return _mdxCompiler;
36
+ try {
37
+ const mod = await import("@mdx-js/mdx");
38
+ _mdxCompiler = mod.compile || mod.default || null;
39
+ } catch {
40
+ _mdxCompiler = null;
41
+ }
42
+ return _mdxCompiler;
43
+ }
44
+ /**
45
+ * Check if `@mdx-js/mdx` is available for real MDX compilation.
46
+ */
47
+ async function isMdxAvailable() {
48
+ return await getMdxCompiler() !== null;
49
+ }
50
+ /**
51
+ * Compile MDX source to a Vue component JavaScript module.
52
+ *
53
+ * Uses `@mdx-js/mdx` to compile the MDX body to a JS module that imports
54
+ * from `@ubean/markdown/jsx-runtime` (which maps to Vue's `h()` function).
55
+ *
56
+ * If `@mdx-js/mdx` is not installed, falls back to plain markdown rendering
57
+ * using `markdown-exit`, wrapping the HTML output in a Vue component.
58
+ *
59
+ * @param source Raw MDX source string (with optional frontmatter)
60
+ * @param options Compilation options
61
+ * @returns Compilation result with code, frontmatter, and compiled flag
62
+ */
63
+ async function compileMdx(source, options = {}) {
64
+ const { data: frontmatter, content } = parseFrontmatter(source);
65
+ const compiler = await getMdxCompiler();
66
+ if (compiler) {
67
+ const compiled = await compiler(options.filePath ? {
68
+ path: options.filePath,
69
+ value: content
70
+ } : content, {
71
+ format: "mdx",
72
+ jsxImportSource: "@ubean/markdown",
73
+ development: options.development ?? false,
74
+ remarkPlugins: options.remarkPlugins || [],
75
+ rehypePlugins: options.rehypePlugins || []
76
+ });
77
+ return {
78
+ code: String(compiled),
79
+ frontmatter,
80
+ compiled: true
81
+ };
82
+ }
83
+ const { markdownToHtml } = await import("./index.js");
84
+ const html = markdownToHtml(content);
85
+ return {
86
+ code: generateFallbackComponent(JSON.stringify(html), frontmatter),
87
+ frontmatter,
88
+ compiled: false
89
+ };
90
+ }
91
+ /**
92
+ * Generate a fallback Vue component when `@mdx-js/mdx` is not available.
93
+ * Renders the markdown HTML using `v-html`.
94
+ */
95
+ function generateFallbackComponent(htmlJson, frontmatter) {
96
+ return `// Auto-generated by @ubean/markdown (fallback mode — @mdx-js/mdx not installed)
97
+ import { defineComponent, h } from 'vue';
98
+ ${Object.keys(frontmatter).length > 0 ? `export const frontmatter = ${JSON.stringify(frontmatter)};` : "export const frontmatter = {};"}
99
+ export default defineComponent({
100
+ name: 'MdxPage',
101
+ setup() {
102
+ return () => h('div', { class: 'ubean-mdx-content', innerHTML: ${htmlJson} });
103
+ }
104
+ });
105
+ `;
106
+ }
107
+ /**
108
+ * Synchronously check if MDX compilation is available (without dynamic import).
109
+ * Uses the cached result from a previous `compileMdx()` or `isMdxAvailable()` call.
110
+ */
111
+ function isMdxAvailableSync() {
112
+ return _mdxCompiler !== void 0 && _mdxCompiler !== null;
113
+ }
114
+ //#endregion
115
+ //#region src/vite-plugin.ts
116
+ const DEFAULT_INCLUDE = /\.mdx$/;
117
+ function ubeanMdxPlugin(options = {}) {
118
+ const include = options.include || DEFAULT_INCLUDE;
119
+ return {
120
+ name: "ubean:mdx",
121
+ enforce: "pre",
122
+ async transform(code, id) {
123
+ if (!include.test(id)) return null;
124
+ if (options.exclude && options.exclude.test(id)) return null;
125
+ try {
126
+ return {
127
+ code: (await compileMdx(code, {
128
+ filePath: id,
129
+ remarkPlugins: options.remarkPlugins,
130
+ rehypePlugins: options.rehypePlugins
131
+ })).code,
132
+ map: {
133
+ mappings: "",
134
+ version: 3,
135
+ sources: [],
136
+ names: [],
137
+ file: id
138
+ }
139
+ };
140
+ } catch (err) {
141
+ this.error(`[ubean:mdx] Failed to compile ${id}: ${err.message}`);
142
+ return null;
143
+ }
144
+ },
145
+ handleHotUpdate(ctx) {
146
+ if (!include.test(ctx.file)) return;
147
+ ctx.server.ws.send({ type: "full-reload" });
148
+ return [];
149
+ }
150
+ };
151
+ }
152
+ //#endregion
153
+ //#region src/index.ts
154
+ const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n?/;
155
+ function parseFrontmatter(source) {
156
+ const match = source.match(FRONTMATTER_REGEX);
157
+ if (!match) return {
158
+ data: {},
159
+ content: source
160
+ };
161
+ const raw = match[1];
162
+ const content = source.slice(match[0].length);
163
+ return {
164
+ data: parseYamlSimple(raw),
165
+ content
166
+ };
167
+ }
168
+ function tokenizeYaml(yaml) {
169
+ const lines = [];
170
+ for (const raw of yaml.split("\n")) {
171
+ const trimmed = raw.trimEnd();
172
+ if (!trimmed.trim() || trimmed.trimStart().startsWith("#")) continue;
173
+ let indent = 0;
174
+ while (indent < trimmed.length && trimmed[indent] === " ") indent++;
175
+ lines.push({
176
+ indent,
177
+ content: trimmed.trim()
178
+ });
179
+ }
180
+ return lines;
181
+ }
182
+ function parseYamlScalar(raw) {
183
+ if (raw === "true" || raw === "false") return raw === "true";
184
+ if (raw === "null" || raw === "~") return null;
185
+ if (raw !== "" && !isNaN(Number(raw))) {
186
+ const num = Number(raw);
187
+ if (isFinite(num)) return num;
188
+ }
189
+ if (raw.startsWith("\"") && raw.endsWith("\"") || raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
190
+ if (raw.startsWith("[") && raw.endsWith("]")) return raw.slice(1, -1).split(",").map((v) => v.trim()).filter((v) => v !== "").map((v) => {
191
+ if (v === "true") return true;
192
+ if (v === "false") return false;
193
+ if (v === "null") return null;
194
+ if (!isNaN(Number(v))) return Number(v);
195
+ return v.replace(/^["']|["']$/g, "");
196
+ });
197
+ return raw;
198
+ }
199
+ function isSequenceItem(content) {
200
+ return content === "-" || content.startsWith("- ");
201
+ }
202
+ /** `- key: value` starts an inline map item (continuation keys align after the dash). */
203
+ function isInlineMapStart(rest) {
204
+ const colonIdx = rest.indexOf(":");
205
+ return colonIdx > 0 && (rest.length === colonIdx + 1 || rest[colonIdx + 1] === " ");
206
+ }
207
+ function assignYamlKey(map, key, value) {
208
+ const nestedKeys = key.split(".");
209
+ let current = map;
210
+ for (let i = 0; i < nestedKeys.length - 1; i++) {
211
+ const k = nestedKeys[i];
212
+ if (typeof current[k] !== "object" || current[k] === null) current[k] = {};
213
+ current = current[k];
214
+ }
215
+ current[nestedKeys[nestedKeys.length - 1]] = value;
216
+ }
217
+ function parseYamlBlock(lines, start, indent) {
218
+ const first = lines[start];
219
+ if (isSequenceItem(first.content)) {
220
+ const items = [];
221
+ let i = start;
222
+ while (i < lines.length && lines[i].indent === indent && isSequenceItem(lines[i].content)) {
223
+ const rest = lines[i].content === "-" ? "" : lines[i].content.slice(2).trim();
224
+ if (rest === "") {
225
+ if (i + 1 < lines.length && lines[i + 1].indent > indent) {
226
+ const res = parseYamlBlock(lines, i + 1, lines[i + 1].indent);
227
+ items.push(res.value);
228
+ i = res.next;
229
+ } else {
230
+ items.push(null);
231
+ i += 1;
232
+ }
233
+ } else if (isInlineMapStart(rest)) {
234
+ const synthesized = [{
235
+ indent: indent + 2,
236
+ content: rest
237
+ }];
238
+ let j = i + 1;
239
+ while (j < lines.length && lines[j].indent > indent) {
240
+ synthesized.push(lines[j]);
241
+ j += 1;
242
+ }
243
+ const res = parseYamlBlock(synthesized, 0, indent + 2);
244
+ items.push(res.value);
245
+ i = j;
246
+ } else {
247
+ items.push(parseYamlScalar(rest));
248
+ i += 1;
249
+ }
250
+ }
251
+ return {
252
+ value: items,
253
+ next: i
254
+ };
255
+ }
256
+ const map = {};
257
+ let i = start;
258
+ while (i < lines.length && lines[i].indent === indent && !isSequenceItem(lines[i].content)) {
259
+ const colonIdx = lines[i].content.indexOf(":");
260
+ if (colonIdx === -1) {
261
+ i += 1;
262
+ continue;
263
+ }
264
+ const key = lines[i].content.slice(0, colonIdx).trim();
265
+ const rawValue = lines[i].content.slice(colonIdx + 1).trim();
266
+ if (!key) {
267
+ i += 1;
268
+ continue;
269
+ }
270
+ if (rawValue === "") {
271
+ if (i + 1 < lines.length && lines[i + 1].indent > indent) {
272
+ const res = parseYamlBlock(lines, i + 1, lines[i + 1].indent);
273
+ assignYamlKey(map, key, res.value);
274
+ i = res.next;
275
+ } else {
276
+ assignYamlKey(map, key, true);
277
+ i += 1;
278
+ }
279
+ } else {
280
+ assignYamlKey(map, key, parseYamlScalar(rawValue));
281
+ i += 1;
282
+ }
283
+ }
284
+ return {
285
+ value: map,
286
+ next: i
287
+ };
288
+ }
289
+ function parseYamlSimple(yaml) {
290
+ const lines = tokenizeYaml(yaml);
291
+ if (lines.length === 0) return {};
292
+ const { value } = parseYamlBlock(lines, 0, lines[0].indent);
293
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
294
+ return value;
295
+ }
296
+ function slugify(text) {
297
+ return text.toLowerCase().replace(/<[^>]*>/g, "").replace(/[^\w\s\u4e00-\u9fa5-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
298
+ }
299
+ function applyHeadingIds(md) {
300
+ const headingOpenRule = md.renderer.rules.heading_open;
301
+ md.renderer.rules.heading_open = (tokens, idx, options, env, self) => {
302
+ const token = tokens[idx];
303
+ const nextToken = tokens[idx + 1];
304
+ if (nextToken && nextToken.type === "inline") {
305
+ const text = nextToken.children?.filter((t) => t.type === "text" || t.type === "code_inline").map((t) => t.content).join("") || "";
306
+ token.attrSet("id", slugify(text));
307
+ }
308
+ if (headingOpenRule) return headingOpenRule(tokens, idx, options, env, self);
309
+ return self.renderToken(tokens, idx, options);
310
+ };
311
+ }
312
+ function createInstance(options) {
313
+ const md = createMarkdownExit({
314
+ html: options.html ?? false,
315
+ linkify: options.linkify ?? true,
316
+ breaks: options.breaks ?? false,
317
+ typographer: options.typographer ?? false,
318
+ highlight: options.highlighter ? (str, lang) => {
319
+ const result = options.highlighter(str.trimEnd(), lang);
320
+ if (result.startsWith("<pre")) return result;
321
+ return `<pre><code class="language-${lang}">${result}</code></pre>`;
322
+ } : void 0
323
+ });
324
+ if (options.headingIds !== false) applyHeadingIds(md);
325
+ return md;
326
+ }
327
+ function markdownToHtml(markdown, options = {}) {
328
+ return createInstance(options).render(markdown).trim();
329
+ }
330
+ function extractHeadings(markdown) {
331
+ const headings = [];
332
+ const tokens = createMarkdownExit({ html: false }).parse(markdown, {});
333
+ function walkTokenList(tokenList) {
334
+ for (let i = 0; i < tokenList.length; i++) {
335
+ const token = tokenList[i];
336
+ if (token.type === "heading_open") {
337
+ const level = parseInt(token.tag.slice(1), 10);
338
+ const inlineToken = tokenList[i + 1];
339
+ if (inlineToken && inlineToken.type === "inline") {
340
+ const text = extractTextFromTokens(inlineToken.children || []);
341
+ headings.push({
342
+ level,
343
+ text,
344
+ id: slugify(text)
345
+ });
346
+ }
347
+ }
348
+ if (token.children) walkTokenList(token.children);
349
+ }
350
+ }
351
+ walkTokenList(tokens);
352
+ return headings;
353
+ }
354
+ function extractTextFromTokens(tokens) {
355
+ let text = "";
356
+ for (const token of tokens) if (token.type === "text" || token.type === "code_inline") text += token.content;
357
+ else if (token.children) text += extractTextFromTokens(token.children);
358
+ return text;
359
+ }
360
+ function extractExcerpt(markdown, separator = "<!-- more -->") {
361
+ const idx = markdown.indexOf(separator);
362
+ if (idx !== -1) return markdown.slice(0, idx).trim();
363
+ const paragraphs = markdown.split("\n\n").filter((p) => p.trim() !== "");
364
+ for (const para of paragraphs) {
365
+ const trimmed = para.trim();
366
+ if (trimmed.startsWith("#")) continue;
367
+ if (trimmed.startsWith("```")) continue;
368
+ if (trimmed.startsWith(">")) continue;
369
+ if (trimmed.startsWith("- ") || trimmed.startsWith("* ") || /^\d+\.\s/.test(trimmed)) continue;
370
+ if (trimmed.length < 500) return trimmed;
371
+ }
372
+ }
373
+ function parseMarkdown(source, options = {}) {
374
+ const { data: frontmatter, content } = parseFrontmatter(source);
375
+ const headings = extractHeadings(content);
376
+ return {
377
+ frontmatter,
378
+ content,
379
+ excerpt: options.excerpt !== false ? extractExcerpt(content, options.excerptSeparator) : void 0,
380
+ headings,
381
+ html: markdownToHtml(content, options)
382
+ };
383
+ }
384
+ function defineMarkdownPage(frontmatter) {
385
+ return frontmatter;
386
+ }
387
+ //#endregion
388
+ export { parseFrontmatter as a, compileMdx as c, markdownToHtml as i, isMdxAvailable as l, extractExcerpt as n, parseMarkdown as o, extractHeadings as r, ubeanMdxPlugin as s, defineMarkdownPage as t, isMdxAvailableSync as u };