@jarenjs/md 0.34.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.
Files changed (49) hide show
  1. package/README.md +520 -0
  2. package/dist/types/ast.d.ts +181 -0
  3. package/dist/types/bake.d.ts +61 -0
  4. package/dist/types/compiler.d.ts +141 -0
  5. package/dist/types/component/index.d.ts +101 -0
  6. package/dist/types/directives.d.ts +126 -0
  7. package/dist/types/entities.d.ts +40 -0
  8. package/dist/types/footnotes.d.ts +83 -0
  9. package/dist/types/frontmatter.d.ts +67 -0
  10. package/dist/types/html.d.ts +72 -0
  11. package/dist/types/index.d.ts +30 -0
  12. package/dist/types/loader.d.ts +84 -0
  13. package/dist/types/mdx.d.ts +45 -0
  14. package/dist/types/parser.d.ts +116 -0
  15. package/dist/types/plugins/highlight.d.ts +64 -0
  16. package/dist/types/plugins/index.d.ts +64 -0
  17. package/dist/types/plugins/mermaid.d.ts +12 -0
  18. package/dist/types/scanner.d.ts +240 -0
  19. package/dist/types/to-html.d.ts +104 -0
  20. package/dist/types/to-md.d.ts +23 -0
  21. package/dist/types/to-vnode.d.ts +161 -0
  22. package/dist/types/utils.d.ts +63 -0
  23. package/docs/LOADER.md +92 -0
  24. package/docs/MD-FORMAT.md +502 -0
  25. package/docs/PLUGINS.md +277 -0
  26. package/package.json +80 -0
  27. package/schemas/jaren-md-ast.schema.json +296 -0
  28. package/src/ast.js +346 -0
  29. package/src/bake.js +104 -0
  30. package/src/compiler.js +167 -0
  31. package/src/component/index.js +191 -0
  32. package/src/directives.js +371 -0
  33. package/src/entities.js +107 -0
  34. package/src/footnotes.js +180 -0
  35. package/src/frontmatter.js +947 -0
  36. package/src/html.js +281 -0
  37. package/src/index.js +76 -0
  38. package/src/loader.js +0 -0
  39. package/src/mdx.js +219 -0
  40. package/src/parser.js +1685 -0
  41. package/src/plugins/highlight.js +325 -0
  42. package/src/plugins/index.js +75 -0
  43. package/src/plugins/mermaid.js +14 -0
  44. package/src/scanner.js +832 -0
  45. package/src/to-html.js +425 -0
  46. package/src/to-md.js +396 -0
  47. package/src/to-vnode.js +766 -0
  48. package/src/utils.js +107 -0
  49. package/styles/md.css +238 -0
@@ -0,0 +1,325 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Syntax highlighting for fenced code blocks (PLUGINS.md §6.2).
4
+ *
5
+ * Built-in mode is a zero-dependency single-pass tokenizer driven by
6
+ * compact grammar tables — a keyword set, comment/string delimiters
7
+ * and punctuation classes — compiled to closures once at module load.
8
+ * No regex runs in the token loop. Adapter mode puts shiki/prism/
9
+ * highlight.js behind the same `{ kind, value }` token contract.
10
+ *
11
+ * Token kinds: kw str num com pun id op lit — rendered as
12
+ * `span.tok-{kind}` (plain `id` runs render as bare text).
13
+ */
14
+
15
+ import { definePlugin } from './index.js';
16
+
17
+ /**
18
+ * @typedef {{ kind: 'kw'|'str'|'num'|'com'|'pun'|'id'|'op'|'lit', value: string }} Token
19
+ */
20
+ /**
21
+ * A compact grammar table.
22
+ * @typedef {object} MdGrammar
23
+ * @property {string[]} [keywords]
24
+ * @property {string[]} [literals]
25
+ * @property {string[]} [lineComments] comment-to-end-of-line prefixes
26
+ * @property {[string, string]} [blockComment] open/close pair
27
+ * @property {string} [strings] string delimiter characters
28
+ * @property {boolean} [numbers] recognize number literals
29
+ * @property {string} [extraId] extra identifier characters (e.g. `-` for CSS)
30
+ */
31
+
32
+ const JS_KEYWORDS = [
33
+ 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue',
34
+ 'debugger', 'default', 'delete', 'do', 'else', 'export', 'extends',
35
+ 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'let',
36
+ 'new', 'of', 'return', 'static', 'super', 'switch', 'this', 'throw',
37
+ 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', 'get', 'set',
38
+ // the TS layer — harmless for plain JS
39
+ 'abstract', 'any', 'as', 'asserts', 'declare', 'enum', 'implements',
40
+ 'infer', 'interface', 'is', 'keyof', 'namespace', 'never', 'private',
41
+ 'protected', 'public', 'readonly', 'satisfies', 'type', 'unknown',
42
+ ];
43
+
44
+ const BASH_KEYWORDS = [
45
+ 'if', 'then', 'elif', 'else', 'fi', 'for', 'while', 'until', 'do',
46
+ 'done', 'case', 'esac', 'in', 'function', 'select', 'time', 'return',
47
+ 'break', 'continue', 'local', 'export', 'readonly', 'declare', 'unset',
48
+ 'shift', 'source', 'alias', 'echo', 'exit', 'set', 'cd', 'test',
49
+ ];
50
+
51
+ /** @type {Record<string, MdGrammar>} */
52
+ const GRAMMARS = {
53
+ js: {
54
+ keywords: JS_KEYWORDS,
55
+ literals: ['true', 'false', 'null', 'undefined', 'NaN', 'Infinity'],
56
+ lineComments: ['//'],
57
+ blockComment: ['/*', '*/'],
58
+ strings: '\'"`',
59
+ numbers: true,
60
+ extraId: '$',
61
+ },
62
+ json: {
63
+ literals: ['true', 'false', 'null'],
64
+ strings: '"',
65
+ numbers: true,
66
+ },
67
+ toml: {
68
+ literals: ['true', 'false', 'null', 'inf', 'nan'],
69
+ lineComments: ['#'],
70
+ strings: '\'"',
71
+ numbers: true,
72
+ extraId: '-',
73
+ },
74
+ html: {
75
+ keywords: ['html', 'head', 'body', 'div', 'span', 'script', 'style',
76
+ 'a', 'p', 'ul', 'ol', 'li', 'table', 'tr', 'td', 'th', 'input',
77
+ 'button', 'form', 'img', 'pre', 'code', 'h1', 'h2', 'h3', 'main',
78
+ 'section', 'article', 'header', 'footer', 'nav', 'template'],
79
+ blockComment: ['<!--', '-->'],
80
+ strings: '\'"',
81
+ extraId: '-',
82
+ },
83
+ css: {
84
+ keywords: ['import', 'media', 'supports', 'keyframes', 'font-face',
85
+ 'root', 'hover', 'focus', 'active', 'before', 'after', 'not',
86
+ 'important'],
87
+ lineComments: [],
88
+ blockComment: ['/*', '*/'],
89
+ strings: '\'"',
90
+ numbers: true,
91
+ extraId: '-#',
92
+ },
93
+ md: {
94
+ lineComments: [],
95
+ strings: '`',
96
+ extraId: '',
97
+ },
98
+ bash: {
99
+ keywords: BASH_KEYWORDS,
100
+ literals: ['true', 'false'],
101
+ lineComments: ['#'],
102
+ strings: '\'"`',
103
+ numbers: true,
104
+ extraId: '-$',
105
+ },
106
+ };
107
+ GRAMMARS.ts = GRAMMARS.js;
108
+ GRAMMARS.jsx = GRAMMARS.js;
109
+ GRAMMARS.tsx = GRAMMARS.js;
110
+ GRAMMARS.mjs = GRAMMARS.js;
111
+ GRAMMARS.cjs = GRAMMARS.js;
112
+ GRAMMARS.javascript = GRAMMARS.js;
113
+ GRAMMARS.typescript = GRAMMARS.js;
114
+ GRAMMARS.josl = GRAMMARS.toml;
115
+ GRAMMARS.ini = GRAMMARS.toml;
116
+ GRAMMARS.yaml = GRAMMARS.toml;
117
+ GRAMMARS.yml = GRAMMARS.toml;
118
+ GRAMMARS.xml = GRAMMARS.html;
119
+ GRAMMARS.markdown = GRAMMARS.md;
120
+ GRAMMARS.sh = GRAMMARS.bash;
121
+ GRAMMARS.shell = GRAMMARS.bash;
122
+ GRAMMARS.console = GRAMMARS.bash;
123
+
124
+ /** The canonical names of the shipped grammars (aliases resolve too). */
125
+ export const GRAMMAR_NAMES = Object.freeze(Object.keys(GRAMMARS));
126
+
127
+ const OP_CHARS = '+-*/%=<>!&|^~?:@';
128
+ const PUN_CHARS = '()[]{},;.';
129
+
130
+ /**
131
+ * Compile a grammar table into a single-pass tokenizer closure.
132
+ * @param {MdGrammar} grammar
133
+ * @returns {(code: string) => Token[]}
134
+ */
135
+ function compileGrammar(grammar) {
136
+ const keywords = new Set(grammar.keywords ?? []);
137
+ const literals = new Set(grammar.literals ?? []);
138
+ const lineComments = grammar.lineComments ?? [];
139
+ const blockOpen = grammar.blockComment !== undefined ? grammar.blockComment[0] : null;
140
+ const blockClose = grammar.blockComment !== undefined ? grammar.blockComment[1] : '';
141
+ const strings = grammar.strings ?? '';
142
+ const numbers = grammar.numbers === true;
143
+ // Per-char class lookup for the ASCII range, built once.
144
+ const CLASS = new Uint8Array(128); // 1 id, 2 op, 3 pun, 4 string, 5 digit
145
+ for (let c = 48; c <= 57; c++) CLASS[c] = 5;
146
+ for (let c = 65; c <= 90; c++) CLASS[c] = 1;
147
+ for (let c = 97; c <= 122; c++) CLASS[c] = 1;
148
+ CLASS[95] = 1; // _
149
+ for (const ch of OP_CHARS) CLASS[ch.charCodeAt(0)] = 2;
150
+ for (const ch of PUN_CHARS) CLASS[ch.charCodeAt(0)] = 3;
151
+ for (const ch of strings) CLASS[ch.charCodeAt(0)] = 4;
152
+ for (const ch of grammar.extraId ?? '') CLASS[ch.charCodeAt(0)] = 1;
153
+
154
+ return function tokenize(code) {
155
+ /** @type {Token[]} */
156
+ const tokens = [];
157
+ let i = 0;
158
+ let plain = 0; // start of the pending plain run
159
+
160
+ /** @param {number} end @param {Token['kind']} kind @param {number} to */
161
+ const push = (end, kind, to) => {
162
+ if (end > plain) tokens.push({ kind: 'id', value: code.slice(plain, end) });
163
+ if (to > end) tokens.push({ kind, value: code.slice(end, to) });
164
+ plain = to;
165
+ i = to;
166
+ };
167
+
168
+ outer:
169
+ while (i < code.length) {
170
+ const c = code.charCodeAt(i);
171
+
172
+ // Comments (prefix comparison, no regex).
173
+ for (let k = 0; k < lineComments.length; k++) {
174
+ if (c === lineComments[k].charCodeAt(0) && code.startsWith(lineComments[k], i)) {
175
+ let end = code.indexOf('\n', i);
176
+ if (end === -1) end = code.length;
177
+ push(i, 'com', end);
178
+ continue outer;
179
+ }
180
+ }
181
+ if (blockOpen !== null && c === blockOpen.charCodeAt(0) && code.startsWith(blockOpen, i)) {
182
+ let end = code.indexOf(blockClose, i + blockOpen.length);
183
+ end = end === -1 ? code.length : end + blockClose.length;
184
+ push(i, 'com', end);
185
+ continue;
186
+ }
187
+
188
+ const cls = c < 128 ? CLASS[c] : 1;
189
+
190
+ if (cls === 4) {
191
+ // String literal with backslash escapes, to line end at most
192
+ // (unterminated strings stay honest).
193
+ let j = i + 1;
194
+ while (j < code.length) {
195
+ const s = code.charCodeAt(j);
196
+ if (s === 0x5C) { j += 2; continue; }
197
+ if (s === c) { j++; break; }
198
+ if (s === 0x0A && c !== 0x60) break;
199
+ j++;
200
+ }
201
+ push(i, 'str', j);
202
+ continue;
203
+ }
204
+
205
+ if (numbers && (cls === 5
206
+ || (c === 0x2E && CLASS[code.charCodeAt(i + 1)] === 5))) {
207
+ let j = i;
208
+ while (j < code.length) {
209
+ const s = code.charCodeAt(j);
210
+ const sc = s < 128 ? CLASS[s] : 0;
211
+ if (sc === 5 || sc === 1 || s === 0x2E || s === 0x5F
212
+ || ((s === 0x2B || s === 0x2D) && (code.charCodeAt(j - 1) | 32) === 0x65)) {
213
+ j++;
214
+ continue;
215
+ }
216
+ break;
217
+ }
218
+ push(i, 'num', j);
219
+ continue;
220
+ }
221
+
222
+ if (cls === 1) {
223
+ let j = i + 1;
224
+ while (j < code.length) {
225
+ const s = code.charCodeAt(j);
226
+ const sc = s < 128 ? CLASS[s] : 1;
227
+ if (sc === 1 || sc === 5) { j++; continue; }
228
+ break;
229
+ }
230
+ const word = code.slice(i, j);
231
+ if (keywords.has(word)) push(i, 'kw', j);
232
+ else if (literals.has(word)) push(i, 'lit', j);
233
+ else i = j; // stays in the plain run
234
+ continue;
235
+ }
236
+
237
+ if (cls === 2) { push(i, 'op', i + 1); continue; }
238
+ if (cls === 3) { push(i, 'pun', i + 1); continue; }
239
+ i++;
240
+ }
241
+ if (code.length > plain) tokens.push({ kind: 'id', value: code.slice(plain) });
242
+ return tokens;
243
+ };
244
+ }
245
+
246
+ /** Compiled tokenizers, one per distinct grammar table. */
247
+ const COMPILED = new Map();
248
+ for (const name of Object.keys(GRAMMARS)) {
249
+ const grammar = GRAMMARS[name];
250
+ if (!COMPILED.has(grammar)) COMPILED.set(grammar, compileGrammar(grammar));
251
+ }
252
+
253
+ /**
254
+ * Tokenize `code` with the built-in grammar for `lang`. Returns null
255
+ * when no grammar covers the language.
256
+ * @param {string} code
257
+ * @param {string|null} lang
258
+ * @returns {Token[] | null}
259
+ */
260
+ export function tokenizeCode(code, lang) {
261
+ if (lang === null) return null;
262
+ const grammar = GRAMMARS[lang];
263
+ if (grammar === undefined) return null;
264
+ return COMPILED.get(grammar)(code);
265
+ }
266
+
267
+ /**
268
+ * The syntax-highlighting plugin: takes over rendering of `code`
269
+ * nodes. `adapter(code, lang)` may return tokens (shiki/prism/hljs
270
+ * behind the shared contract) or null to fall back to the built-in
271
+ * grammars, then to plain text.
272
+ *
273
+ * @param {{ grammars?: Record<string, MdGrammar>,
274
+ * adapter?: (code: string, lang: string|null) => (Token[] | null) }} [config]
275
+ * @returns {import('./index.js').MdPlugin}
276
+ */
277
+ export function highlightPlugin(config = {}) {
278
+ const adapter = config.adapter;
279
+ /** @type {Map<string, (code: string) => Token[]>} */
280
+ const extra = new Map();
281
+ if (config.grammars !== undefined) {
282
+ for (const name of Object.keys(config.grammars)) {
283
+ extra.set(name, compileGrammar(config.grammars[name]));
284
+ }
285
+ }
286
+
287
+ /**
288
+ * @param {string} code
289
+ * @param {string|null} lang
290
+ * @returns {Token[] | null}
291
+ */
292
+ const tokensFor = (code, lang) => {
293
+ if (adapter !== undefined) {
294
+ const tokens = adapter(code, lang);
295
+ if (tokens !== null && tokens !== undefined) return tokens;
296
+ }
297
+ if (lang !== null) {
298
+ const custom = extra.get(lang);
299
+ if (custom !== undefined) return custom(code);
300
+ }
301
+ return tokenizeCode(code, lang);
302
+ };
303
+
304
+ return definePlugin({
305
+ name: 'highlight',
306
+ node: 'code',
307
+ render: (node, h) => {
308
+ const lang = node.lang ?? null;
309
+ const props = lang === null ? {} : { class: 'language-' + lang };
310
+ const tokens = tokensFor(node.value, lang);
311
+ if (tokens === null) {
312
+ return h('pre', {}, h('code', props, node.value));
313
+ }
314
+ /** @type {any[]} */
315
+ const children = [];
316
+ for (let i = 0; i < tokens.length; i++) {
317
+ const token = tokens[i];
318
+ children.push(token.kind === 'id'
319
+ ? token.value
320
+ : h('span', { class: 'tok-' + token.kind }, token.value));
321
+ }
322
+ return h('pre', {}, h('code', props, ...children));
323
+ },
324
+ });
325
+ }
@@ -0,0 +1,75 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Plugin registry helpers and the reference plugins.
4
+ *
5
+ * A plugin is data, validated and frozen at definition time; the
6
+ * parser and vnode emitter bake plugin arrays into dispatch tables at
7
+ * compile time (the normative contract lives in docs/PLUGINS.md).
8
+ */
9
+
10
+ const RE_KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
11
+
12
+ /**
13
+ * @typedef {object} MdPlugin
14
+ * @property {string} name unique kebab-case identifier
15
+ * @property {string[]} [fences] fenced-code claims by info-string first word
16
+ * @property {any[]} [blocks] block rule descriptors ({ chars, start, continue, close })
17
+ * @property {any[]} [inlines] inline rule descriptors ({ char, scan })
18
+ * @property {string} [node] the AST type this plugin emits/renders
19
+ * @property {(node: any, h: any, ctx: any) => any} [render] pure vnode renderer
20
+ * @property {(node: any, ctx: any) => string} [toHtml] pure HTML-string
21
+ * renderer, for `toHtml`. Independent of `render`: a plugin may serve
22
+ * one emitter, the other, or both (docs/PLUGINS.md §5.1)
23
+ * @property {(el: any, node: any, ctx: any) => any} [hydrate] browser-only upgrade
24
+ */
25
+
26
+ /**
27
+ * Validate and freeze a plugin spec (docs/PLUGINS.md §1).
28
+ * @param {MdPlugin} spec
29
+ * @returns {MdPlugin}
30
+ */
31
+ export function definePlugin(spec) {
32
+ if (spec === null || typeof spec !== 'object') {
33
+ throw new TypeError('md plugin: spec must be an object');
34
+ }
35
+ if (typeof spec.name !== 'string' || !RE_KEBAB.test(spec.name)) {
36
+ throw new TypeError(`md plugin: name must be kebab-case, got '${spec.name}'`);
37
+ }
38
+ if (spec.fences !== undefined) {
39
+ if (!Array.isArray(spec.fences) || spec.fences.some((f) => typeof f !== 'string')) {
40
+ throw new TypeError(`md plugin '${spec.name}': fences must be an array of strings`);
41
+ }
42
+ Object.freeze(spec.fences);
43
+ }
44
+ if (spec.blocks !== undefined) {
45
+ for (const rule of spec.blocks) {
46
+ if (typeof rule.chars !== 'string' || rule.chars.length === 0
47
+ || typeof rule.start !== 'function' || typeof rule.continue !== 'function') {
48
+ throw new TypeError(`md plugin '${spec.name}': block rules need chars, start and continue`);
49
+ }
50
+ Object.freeze(rule);
51
+ }
52
+ Object.freeze(spec.blocks);
53
+ }
54
+ if (spec.inlines !== undefined) {
55
+ for (const rule of spec.inlines) {
56
+ if (typeof rule.char !== 'string' || rule.char.length !== 1
57
+ || typeof rule.scan !== 'function') {
58
+ throw new TypeError(`md plugin '${spec.name}': inline rules need a single char and scan`);
59
+ }
60
+ Object.freeze(rule);
61
+ }
62
+ Object.freeze(spec.inlines);
63
+ }
64
+ if ((spec.fences !== undefined || spec.node !== undefined)
65
+ && spec.render !== undefined && typeof spec.render !== 'function') {
66
+ throw new TypeError(`md plugin '${spec.name}': render must be a function`);
67
+ }
68
+ if (spec.toHtml !== undefined && typeof spec.toHtml !== 'function') {
69
+ throw new TypeError(`md plugin '${spec.name}': toHtml must be a function`);
70
+ }
71
+ return Object.freeze(spec);
72
+ }
73
+
74
+ export { highlightPlugin, tokenizeCode, GRAMMAR_NAMES } from './highlight.js';
75
+ export { mermaidPlugin } from './mermaid.js';
@@ -0,0 +1,14 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Mermaid diagram support (PLUGINS.md §6.1).
4
+ *
5
+ * The **native** plugin from `@jarenjs/mermaid`: a self-frozen
6
+ * `MdPlugin`-shaped object whose `render` parses the fence source and
7
+ * emits pure-vnode SVG synchronously (SSR-safe, no injected `mermaid`
8
+ * instance, no `innerHTML`). The dependency arrow points md → mermaid
9
+ *, and `@jarenjs/mermaid/plugin` does not import
10
+ * `definePlugin`, so there is no cycle. Consumers who never use it
11
+ * tree-shake it away (`sideEffects:false`).
12
+ */
13
+
14
+ export { mermaidPlugin, refreshMermaidFence } from '@jarenjs/mermaid/plugin';