comark 0.4.0 → 0.5.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 (48) hide show
  1. package/README.md +25 -1
  2. package/dist/context.d.ts +78 -0
  3. package/dist/context.js +127 -0
  4. package/dist/devtools/bridge.d.ts +1 -0
  5. package/dist/devtools/bridge.js +1 -0
  6. package/dist/devtools/constants.d.ts +1 -0
  7. package/dist/devtools/constants.js +1 -0
  8. package/dist/devtools/index.d.ts +1 -0
  9. package/dist/devtools/index.js +1 -0
  10. package/dist/devtools/register.d.ts +1 -0
  11. package/dist/devtools/register.js +1 -0
  12. package/dist/devtools/registry.d.ts +1 -0
  13. package/dist/devtools/registry.js +1 -0
  14. package/dist/devtools/renderer/dom.d.ts +1 -0
  15. package/dist/devtools/renderer/dom.js +1 -0
  16. package/dist/devtools/renderer/index.d.ts +2 -0
  17. package/dist/devtools/renderer/index.js +2 -0
  18. package/dist/devtools/renderer/output.d.ts +1 -0
  19. package/dist/devtools/renderer/output.js +1 -0
  20. package/dist/devtools/renderer/panel.d.ts +1 -0
  21. package/dist/devtools/renderer/panel.js +1 -0
  22. package/dist/devtools/renderer/styles.d.ts +1 -0
  23. package/dist/devtools/renderer/styles.js +1 -0
  24. package/dist/devtools/renderer/theme.d.ts +1 -0
  25. package/dist/devtools/renderer/theme.js +1 -0
  26. package/dist/devtools/types.d.ts +1 -0
  27. package/dist/devtools/types.js +1 -0
  28. package/dist/devtools/vite.d.ts +1 -0
  29. package/dist/devtools/vite.js +1 -0
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.js +2 -0
  32. package/dist/internal/parse/auto-close/index.js +55 -16
  33. package/dist/internal/stringify/attributes.d.ts +1 -1
  34. package/dist/internal/stringify/attributes.js +10 -9
  35. package/dist/internal/stringify/handlers/li.js +15 -8
  36. package/dist/internal/stringify/handlers/ol.js +4 -1
  37. package/dist/internal/stringify/state.js +13 -1
  38. package/dist/parse.js +1 -1
  39. package/dist/plugins/highlight.js +0 -1
  40. package/dist/plugins/security.d.ts +11 -0
  41. package/dist/plugins/security.js +13 -6
  42. package/dist/plugins/syntax.js +25 -2
  43. package/dist/render.d.ts +6 -2
  44. package/dist/render.js +2 -2
  45. package/dist/types.d.ts +5 -0
  46. package/dist/utils/index.d.ts +3 -1
  47. package/dist/utils/index.js +30 -14
  48. package/package.json +4 -2
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  [![Documentation](https://img.shields.io/badge/Documentation-black?logo=readme&logoColor=white)](https://comark.dev)
9
9
  [![license](https://img.shields.io/github/license/comarkdown/comark?color=black)](https://github.com/comarkdown/comark/blob/main/LICENSE)
10
10
 
11
- A high-performance markdown parser and renderer with Vue, React, Svelte, HTML and ANSI terminal.
11
+ A high-performance markdown parser and renderer with Vue, React, Svelte, Angular, HTML and ANSI terminal.
12
12
 
13
13
  ## Features
14
14
 
@@ -80,6 +80,30 @@ pnpm add @comark/svelte katex
80
80
  <Comark markdown={chatMessage} components={{ math: Math }} plugins={[math()]} />
81
81
  ```
82
82
 
83
+ ### Angular
84
+
85
+ ```bash
86
+ npm install @comark/angular katex
87
+ # or
88
+ pnpm add @comark/angular katex
89
+ ```
90
+
91
+ ```typescript
92
+ import { Component } from '@angular/core'
93
+ import { ComarkComponent } from '@comark/angular'
94
+ import math, { Math } from '@comark/angular/plugins/math'
95
+
96
+ @Component({
97
+ selector: 'app-chat',
98
+ standalone: true,
99
+ imports: [ComarkComponent],
100
+ template: `<comark [markdown]="chatMessage" [components]="{ Math }" [plugins]="[math()]" />`,
101
+ })
102
+ export class ChatComponent {
103
+ chatMessage = ...
104
+ }
105
+ ```
106
+
83
107
  ### HTML (No Framework)
84
108
 
85
109
  ```bash
@@ -0,0 +1,78 @@
1
+ import type { ComarkNode, ComarkTree } from './types.ts';
2
+ /**
3
+ * A patch describes a surgical mutation of a {@link ComarkTree}.
4
+ *
5
+ * `path` is a node index path into `tree.nodes`: the first segment indexes
6
+ * into `tree.nodes`, each subsequent segment indexes into the *children* of
7
+ * the addressed element. Because `ComarkElement` is `[tag, attrs, ...children]`,
8
+ * child index `i` is resolved against array slot `i + 2` internally — callers
9
+ * always work in plain child indices.
10
+ *
11
+ * @example `{ op: 'replace', path: [2, 0], node: 'updated' }`
12
+ * replaces the first child of the third top-level node.
13
+ */
14
+ export type ComarkPatch = {
15
+ op: 'replace';
16
+ path: number[];
17
+ node: ComarkNode;
18
+ } | {
19
+ op: 'insert';
20
+ path: number[];
21
+ node: ComarkNode;
22
+ } | {
23
+ op: 'remove';
24
+ path: number[];
25
+ } | {
26
+ op: 'meta';
27
+ meta: Record<string, unknown>;
28
+ } | {
29
+ op: 'frontmatter';
30
+ frontmatter: Record<string, unknown>;
31
+ } | {
32
+ op: 'data';
33
+ data: Record<string, unknown>;
34
+ };
35
+ /** A single live document: the per-id handle returned by `context.get(id)`. */
36
+ export interface ComarkDocument {
37
+ /** The current tree. Replaced wholesale on `set`, structurally on `patch`. */
38
+ readonly tree: ComarkTree;
39
+ /** Replace the whole tree (e.g. an HMR re-parse or an agent rewrite). */
40
+ set(tree: ComarkTree): void;
41
+ /** Apply one or more patches against the current tree (structural sharing). */
42
+ patch(patch: ComarkPatch | ComarkPatch[]): void;
43
+ /** Subscribe to tree changes. Returns the cleanup function. */
44
+ listen(fn: (tree: ComarkTree) => void): (clear?: boolean) => void;
45
+ }
46
+ /**
47
+ * Ambient registry a `ComarkRenderer` subscribes to so external sources can
48
+ * drive a mounted document by `id`. A renderer calls `get(id).listen(fn)` on
49
+ * mount and the returned cleanup on unmount; drivers (HMR, devtools, collab,
50
+ * agents) call `get(id).set` / `.patch` to push new trees to every renderer
51
+ * with that id.
52
+ *
53
+ * The context is opt-in: a renderer only wires up when `globalThis.comarkContext`
54
+ * exists, so it costs one global lookup when absent and tree-shakes out of any
55
+ * build that never sets it.
56
+ */
57
+ /** Document lifecycle event emitted by the context. */
58
+ export interface ComarkContextEvent {
59
+ event: 'create' | 'remove';
60
+ id: string;
61
+ tree: ComarkTree;
62
+ }
63
+ export interface ComarkContext {
64
+ /** Get the document for `id`, creating it (with `initial`) on first access. */
65
+ get(id: string, initial?: ComarkTree): ComarkDocument;
66
+ /** Ids currently tracked — for devtools enumeration. */
67
+ keys(): string[];
68
+ /** Listen for documents being created or removed. Returns the cleanup. */
69
+ listen(fn: (e: ComarkContextEvent) => void): () => void;
70
+ }
71
+ declare global {
72
+ var comarkContext: ComarkContext | undefined;
73
+ }
74
+ /**
75
+ * Create a {@link ComarkContext} and (by default) install it on
76
+ * `globalThis.comarkContext`. Returns the context.
77
+ */
78
+ export declare function createComarkContext(install?: boolean): ComarkContext;
@@ -0,0 +1,127 @@
1
+ function emptyTree() {
2
+ return { nodes: [], frontmatter: {}, meta: {} };
3
+ }
4
+ /**
5
+ * Apply a node-level patch to a node list, returning a **new** list with
6
+ * structural sharing: only the arrays along `path` are cloned, untouched
7
+ * siblings and branches keep their references so framework identity checks
8
+ * only re-render what actually changed.
9
+ */
10
+ function patchNodeList(nodes, path, patch) {
11
+ const [index, ...rest] = path;
12
+ const next = nodes.slice();
13
+ if (rest.length === 0) {
14
+ if (patch.op === 'replace') {
15
+ if (index < 0 || index >= next.length) {
16
+ throw new Error(`Comark patch: cannot replace out-of-range index [${index}]`);
17
+ }
18
+ next[index] = patch.node;
19
+ }
20
+ else if (patch.op === 'insert') {
21
+ next.splice(index, 0, patch.node);
22
+ }
23
+ else if (patch.op === 'remove') {
24
+ if (index < 0 || index >= next.length) {
25
+ throw new Error(`Comark patch: cannot remove out-of-range index [${index}]`);
26
+ }
27
+ next.splice(index, 1);
28
+ }
29
+ return next;
30
+ }
31
+ const target = next[index];
32
+ if (!Array.isArray(target) || target[0] === null) {
33
+ throw new Error(`Comark patch: path segment [${index}] does not point to an element`);
34
+ }
35
+ const element = target;
36
+ const children = element.slice(2);
37
+ next[index] = [element[0], element[1], ...patchNodeList(children, rest, patch)];
38
+ return next;
39
+ }
40
+ function applyPatch(current, patch) {
41
+ switch (patch.op) {
42
+ case 'meta':
43
+ return { ...current, meta: { ...current.meta, ...patch.meta } };
44
+ case 'frontmatter':
45
+ return { ...current, frontmatter: { ...current.frontmatter, ...patch.frontmatter } };
46
+ case 'data':
47
+ // @ts-expect-error - patch.data is a plain object
48
+ return { ...current, data: { ...current.data, ...patch.data } };
49
+ default: {
50
+ if (!patch.path.length) {
51
+ throw new Error(`Comark patch "${patch.op}" requires a non-empty path`);
52
+ }
53
+ return { ...current, nodes: patchNodeList(current.nodes, patch.path, patch) };
54
+ }
55
+ }
56
+ }
57
+ function createDocument(initial, onEmpty) {
58
+ let tree = initial;
59
+ const listeners = new Set();
60
+ const emit = () => listeners.forEach((fn) => fn(tree));
61
+ return {
62
+ get tree() {
63
+ return tree;
64
+ },
65
+ set(next) {
66
+ tree = next;
67
+ emit();
68
+ },
69
+ patch(patch) {
70
+ const patches = Array.isArray(patch) ? patch : [patch];
71
+ for (const p of patches)
72
+ tree = applyPatch(tree, p);
73
+ emit();
74
+ },
75
+ listen(fn) {
76
+ listeners.add(fn);
77
+ return (clear = false) => {
78
+ if (!listeners.has(fn)) {
79
+ return;
80
+ }
81
+ if (clear) {
82
+ listeners.clear();
83
+ }
84
+ else {
85
+ listeners.delete(fn);
86
+ }
87
+ // Drop the document once nobody is listening — frees ids on unmount.
88
+ if (listeners.size === 0)
89
+ onEmpty(tree);
90
+ };
91
+ },
92
+ };
93
+ }
94
+ /**
95
+ * Create a {@link ComarkContext} and (by default) install it on
96
+ * `globalThis.comarkContext`. Returns the context.
97
+ */
98
+ export function createComarkContext(install = true) {
99
+ if (install && globalThis.comarkContext) {
100
+ return globalThis.comarkContext;
101
+ }
102
+ const docs = new Map();
103
+ const lifecycle = new Set();
104
+ const emit = (e) => lifecycle.forEach((fn) => fn(e));
105
+ const ctx = {
106
+ get(id, initial) {
107
+ let doc = docs.get(id);
108
+ if (!doc) {
109
+ docs.set(id, (doc = createDocument(initial ?? emptyTree(), (tree) => {
110
+ docs.delete(id);
111
+ emit({ event: 'remove', id, tree });
112
+ })));
113
+ emit({ event: 'create', id, tree: doc.tree });
114
+ }
115
+ return doc;
116
+ },
117
+ keys: () => [...docs.keys()],
118
+ listen(fn) {
119
+ lifecycle.add(fn);
120
+ return () => lifecycle.delete(fn);
121
+ },
122
+ };
123
+ if (install)
124
+ globalThis.comarkContext = ctx;
125
+ return ctx;
126
+ }
127
+ // #endregion
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/bridge'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/bridge.ts'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/constants'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/constants.ts'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/index'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/index.ts'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/register'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/register.ts'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/registry'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/registry.ts'
@@ -0,0 +1 @@
1
+ export * from '../../../src/devtools/renderer/dom'
@@ -0,0 +1 @@
1
+ export * from '../../../src/devtools/renderer/dom.ts'
@@ -0,0 +1,2 @@
1
+ export * from '../../../src/devtools/renderer/index'
2
+ export { default } from '../../../src/devtools/renderer/index'
@@ -0,0 +1,2 @@
1
+ export * from '../../../src/devtools/renderer/index.ts'
2
+ export { default } from '../../../src/devtools/renderer/index.ts'
@@ -0,0 +1 @@
1
+ export * from '../../../src/devtools/renderer/output'
@@ -0,0 +1 @@
1
+ export * from '../../../src/devtools/renderer/output.ts'
@@ -0,0 +1 @@
1
+ export * from '../../../src/devtools/renderer/panel'
@@ -0,0 +1 @@
1
+ export * from '../../../src/devtools/renderer/panel.ts'
@@ -0,0 +1 @@
1
+ export * from '../../../src/devtools/renderer/styles'
@@ -0,0 +1 @@
1
+ export * from '../../../src/devtools/renderer/styles.ts'
@@ -0,0 +1 @@
1
+ export * from '../../../src/devtools/renderer/theme'
@@ -0,0 +1 @@
1
+ export * from '../../../src/devtools/renderer/theme.ts'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/types'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/types.ts'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/vite'
@@ -0,0 +1 @@
1
+ export * from '../../src/devtools/vite.ts'
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { autoCloseMarkdown } from './internal/parse/auto-close/index.ts';
2
2
  export { applyAutoUnwrap } from './internal/parse/auto-unwrap.ts';
3
3
  export * from './parse.ts';
4
+ export { createComarkContext } from './context.ts';
5
+ export type { ComarkContext, ComarkDocument, ComarkPatch } from './context.ts';
4
6
  export type * from './types';
package/dist/index.js CHANGED
@@ -4,3 +4,5 @@ export { autoCloseMarkdown } from "./internal/parse/auto-close/index.js";
4
4
  export { applyAutoUnwrap } from "./internal/parse/auto-unwrap.js";
5
5
  // Re-export parse utilities
6
6
  export * from "./parse.js";
7
+ // Re-export the ambient renderer context for live updates
8
+ export { createComarkContext } from "./context.js";
@@ -235,9 +235,31 @@ function closeInlineMarkersLinear(line) {
235
235
  let inAttributes = 0;
236
236
  let inLinkText = 0;
237
237
  let inLinkUrl = 0;
238
+ let linkTextBacktickCount = 0;
239
+ // Markers inside an inline code span (`...`) or math ($...$) are literal text
240
+ let inCode = false;
241
+ let inMath = false;
238
242
  for (let i = 0; i < len; i++) {
239
243
  const prevCh = i > 0 ? line[i - 1] : '';
240
244
  const ch = line[i];
245
+ if (ch === '\\') {
246
+ i++; // Backslash escapes the next char, so neither is a delimiter
247
+ continue;
248
+ }
249
+ if (inCode) {
250
+ if (ch === '`') {
251
+ backtickCount++;
252
+ inCode = false;
253
+ }
254
+ continue;
255
+ }
256
+ if (inMath) {
257
+ if (ch === '$' && !(i + 1 < len && line[i + 1] === '$')) {
258
+ dollarCount++;
259
+ inMath = false;
260
+ }
261
+ continue;
262
+ }
241
263
  if (ch === '{' && prevCh !== ' ') {
242
264
  inAttributes++;
243
265
  continue;
@@ -278,8 +300,30 @@ function closeInlineMarkersLinear(line) {
278
300
  }
279
301
  continue;
280
302
  }
281
- if (inLinkText > 0 || inLinkUrl > 0)
303
+ if (inLinkText > 0) {
304
+ if (ch === '`')
305
+ linkTextBacktickCount++;
306
+ continue;
307
+ }
308
+ if (inLinkUrl > 0)
309
+ continue;
310
+ if (ch === '`') {
311
+ backtickCount++;
312
+ inCode = true;
313
+ continue;
314
+ }
315
+ if (ch === '$') {
316
+ if (i + 1 < len && line[i + 1] === '$') {
317
+ dollarPairCount++; // `$$` is block math, counted as a pair
318
+ dollarCount += 2;
319
+ i++;
320
+ }
321
+ else {
322
+ dollarCount++;
323
+ inMath = true;
324
+ }
282
325
  continue;
326
+ }
283
327
  if (ch === '*') {
284
328
  asteriskCount++;
285
329
  // Track ** positions (not part of ***)
@@ -312,20 +356,13 @@ function closeInlineMarkersLinear(line) {
312
356
  singleTildeCount++;
313
357
  }
314
358
  }
315
- else if (ch === '`') {
316
- backtickCount++;
317
- }
318
- else if (ch === '$' && prevCh !== '\\') {
319
- // Count $$ pairs for block/display math
320
- if (i + 1 < len && line[i + 1] === '$') {
321
- dollarPairCount++;
322
- dollarCount += 2; // Count both dollars in the pair
323
- i++; // Skip next $ since we counted the pair
324
- }
325
- else {
326
- dollarCount++; // Single $ for inline math
327
- }
328
- }
359
+ }
360
+ // Open code/math region: close only it; everything after the opener is literal
361
+ if (inCode) {
362
+ return line + '`';
363
+ }
364
+ if (inMath) {
365
+ return line.trim() === '$$' ? line : line + '$';
329
366
  }
330
367
  // Check for complete ** pairs in O(1) - pairs are matched left to right
331
368
  const hasCompleteBoldPair = doubleAsteriskPositions.length >= 2;
@@ -512,7 +549,9 @@ function closeInlineMarkersLinear(line) {
512
549
  }
513
550
  // Check [ ] (brackets)
514
551
  if (!closingSuffix && bracketBalance > 0) {
515
- closingSuffix = ']';
552
+ // An odd backtick opened inside the unclosed link text is an unclosed
553
+ // inline code span; close it before the bracket so `]` stays outside it.
554
+ closingSuffix = linkTextBacktickCount % 2 === 1 ? '`]' : ']';
516
555
  }
517
556
  // Check ( ) (parens)
518
557
  if (!closingSuffix && parenBalance > 0) {
@@ -5,7 +5,7 @@ export interface ResolveAttributesOptions {
5
5
  * `:` prefix is always stripped. Non-JSON strings fall back to a dot-path
6
6
  * lookup in `renderData`; unresolved paths yield `undefined`.
7
7
  *
8
- * This matches the Vue/React/Svelte renderer semantics, which always
8
+ * This matches the Vue/React/Svelte/Angular renderer semantics, which always
9
9
  * normalize bindings into real JS values suitable for typed component props.
10
10
  *
11
11
  * When false (default) only dot-path lookups are applied — literals and
@@ -72,14 +72,15 @@ export function resolveAttribute(attrs, renderData, key) {
72
72
  // is implicit in `- [ ]`) so they should not echo back as user attrs.
73
73
  const IMPLICIT_ATTRS = {
74
74
  blockquote: { drop: ['as'] },
75
+ ol: { drop: ['start'] },
75
76
  ul: { classBlocklist: ['contains-task-list'] },
76
77
  li: { classBlocklist: ['task-list-item'] },
77
78
  // `language`/`filename`/`highlights`/`meta` ride on the fence info string.
78
- // `tabindex`/`style` come from render-time plugins (e.g. shiki) and have no
79
- // markdown form. `class` is handled specially in userBlockAttrs because shiki
80
- // merges its injected classes with the user's class — we need to strip just
81
- // the highlighter portion.
82
- pre: { drop: ['language', 'filename', 'highlights', 'meta', 'tabindex', 'style'] },
79
+ // `style` comes from render-time plugins (e.g. shiki) and has no markdown
80
+ // form. `class` is handled specially in userBlockAttrs because shiki merges
81
+ // its injected classes with the user's class — we need to strip just the
82
+ // highlighter portion.
83
+ pre: { drop: ['language', 'filename', 'highlights', 'meta', 'style'] },
83
84
  };
84
85
  /**
85
86
  * Filter implicit/auto-generated attrs that are encoded by the native
@@ -104,10 +105,10 @@ export function userBlockAttrs(tag, attributes) {
104
105
  result[key] = remaining;
105
106
  continue;
106
107
  }
107
- if (key === 'class' && tag === 'pre' && typeof value === 'string' && value.startsWith('shiki ')) {
108
- // Shiki injects `shiki [shiki-themes] <themes…> dark:<theme>` and any
109
- // user-supplied class is appended after it. Recover the user portion by
110
- // dropping everything up to and including the first `dark:*` token.
108
+ if (key === 'class' && tag === 'pre' && typeof value === 'string' && value.startsWith('shiki')) {
109
+ // Shiki injects `shiki [shiki-themes] <themes…>` (or a bare `shiki`) and
110
+ // appends any user class after a `.` separator. Recover the user portion
111
+ // by dropping everything up to and including that separator.
111
112
  const tokens = value.split(/\s+/);
112
113
  let cutoff = tokens.findIndex((t) => t === '.');
113
114
  const userClass = cutoff >= 0 ? tokens.slice(cutoff + 1).join(' ') : '';
@@ -16,23 +16,30 @@ export async function li(node, state) {
16
16
  prefix += input[1].checked || input[1][':checked'] ? '[x] ' : '[ ] ';
17
17
  }
18
18
  const prefixWidth = prefix.length;
19
+ // Direct text children render sibling components inline.
20
+ const hasInlineContent = children.some((child) => typeof child === 'string');
19
21
  let result = '';
20
22
  for (const child of children) {
21
- const rendered = await state.one(child, state, node);
22
- if (result && Array.isArray(child)) {
23
- if (blockElements.has(child[0])) {
24
- // Block-level child: put on its own line and indent to align with list prefix
25
- const indented = indent(rendered, { width: prefixWidth });
23
+ if (Array.isArray(child)) {
24
+ const tag = child[0];
25
+ if (result && blockElements.has(tag)) {
26
+ const indented = indent(await state.one(child, state, node), { width: prefixWidth });
26
27
  result = result.trimEnd() + '\n' + indented.trimEnd() + '\n';
27
28
  continue;
28
29
  }
29
- if (child[0] === 'p') {
30
- const indented = indent(rendered, { width: prefixWidth });
30
+ if (result && tag === 'p') {
31
+ const indented = indent(await state.one(child, state, node), { width: prefixWidth });
31
32
  result = result.trimEnd() + '\n\n' + indented.trimEnd() + '\n';
32
33
  continue;
33
34
  }
35
+ // No parent → mdc skips its own nesting indentation, so li owns it here.
36
+ if (!hasInlineContent && !(tag in state.handlers)) {
37
+ const indented = indent(await state.one(child, state), { width: prefixWidth, ignoreFirstLine: !result });
38
+ result = result ? result.trimEnd() + '\n' + indented.trimEnd() + '\n' : indented.trimEnd() + '\n';
39
+ continue;
40
+ }
34
41
  }
35
- result += rendered;
42
+ result += await state.one(child, state, node);
36
43
  }
37
44
  result = result.trim();
38
45
  const attrs = comarkAttributes(userBlockAttrs('li', node[1]));
@@ -2,7 +2,10 @@ import { indent } from "../../../utils/index.js";
2
2
  import { comarkAttributes, userBlockAttrs } from "../attributes.js";
3
3
  export async function ol(node, state) {
4
4
  const children = node.slice(2);
5
- const revert = state.applyContext({ list: true, order: 1, listIndent: 3 });
5
+ // `start` is carried by the native numbering; IMPLICIT_ATTRS drops it from user attrs.
6
+ const start = Number(node[1].start);
7
+ const order = Number.isInteger(start) && start >= 1 ? start : 1;
8
+ const revert = state.applyContext({ list: true, order, listIndent: 3 });
6
9
  let result = '';
7
10
  for (const child of children) {
8
11
  result += await state.one(child, state);
@@ -25,7 +25,7 @@ export async function one(node, state, parent) {
25
25
  if (state.context.html) {
26
26
  return escapeHtml(node);
27
27
  }
28
- return node;
28
+ return escapeMarkdownText(node);
29
29
  }
30
30
  if (node[0] === null) {
31
31
  return await state.handlers.comment(node, state);
@@ -175,3 +175,15 @@ function escapeHtml(text) {
175
175
  };
176
176
  return text.replace(/[<>]/g, (char) => map[char]);
177
177
  }
178
+ /**
179
+ * Escape characters in a markdown text node that would otherwise be
180
+ * misinterpreted as markdown syntax on a subsequent parse.
181
+ *
182
+ * `[` opens link/image syntax; `]` closes it. Both must be escaped so that
183
+ * a text node like `[foo](bar)` round-trips as plain text, and a text node
184
+ * containing `]` inside a link (e.g. `dsd]dsd`) doesn't prematurely close
185
+ * the surrounding `[…]` brackets.
186
+ */
187
+ function escapeMarkdownText(text) {
188
+ return text.replace(/[[\]]/g, (ch) => `\\${ch}`);
189
+ }
package/dist/parse.js CHANGED
@@ -53,7 +53,7 @@ export function createParse(options = {}) {
53
53
  plugins.unshift(alert());
54
54
  const parser = new MarkdownExit({
55
55
  html: false,
56
- linkify: true,
56
+ linkify: options.linkify ?? true,
57
57
  }).enable(['table', 'strikethrough']);
58
58
  if (options.html !== false) {
59
59
  parser.inline.ruler.before('text', 'comark_html_inline', html_inline);
@@ -284,7 +284,6 @@ export async function highlightCodeBlocks(tree, options = {}) {
284
284
  const newPreAttrs = {
285
285
  ...preAttrs,
286
286
  class: userClass ? `${classStr} . ${userClass}` : classStr,
287
- tabindex: '0',
288
287
  };
289
288
  if (options.preStyles) {
290
289
  const lightTheme = options.themes?.light;
@@ -1,3 +1,4 @@
1
+ import type { ComarkElement, ComarkNode } from 'comark';
1
2
  import type { PropsValidationOptions } from '../internal/props-validation.ts';
2
3
  interface SecurityOptions extends PropsValidationOptions {
3
4
  /**
@@ -5,6 +6,16 @@ interface SecurityOptions extends PropsValidationOptions {
5
6
  * @default []
6
7
  */
7
8
  blockedTags?: string[];
9
+ /**
10
+ * Tags to allow only in the output tree.
11
+ * @default []
12
+ */
13
+ allowedTags?: string[];
14
+ /**
15
+ * Behavior when encountering an unallowed or blocked tag.
16
+ * @default undefined
17
+ */
18
+ tagFallback?: (element: ComarkElement) => false | ComarkNode | Promise<false | ComarkNode>;
8
19
  }
9
20
  declare const _default: import("comark").ComarkPluginFactory<SecurityOptions, {}, {}>;
10
21
  export default _default;
@@ -1,9 +1,10 @@
1
1
  import { defineComarkPlugin } from "../utils/helpers.js";
2
- import { visit } from "../utils/index.js";
2
+ import { visitAsync } from "../utils/index.js";
3
3
  import { validateProps } from "../internal/props-validation.js";
4
4
  export default defineComarkPlugin((options = {}) => {
5
- const { blockedTags = [], allowedLinkPrefixes, allowedImagePrefixes, allowedProtocols, defaultOrigin, allowDataImages, } = options;
5
+ const { blockedTags = [], allowedTags = [], tagFallback = undefined, allowedLinkPrefixes, allowedImagePrefixes, allowedProtocols, defaultOrigin, allowDataImages, } = options;
6
6
  const dropSet = new Set(blockedTags.map((t) => t.toLowerCase()));
7
+ const allowSet = new Set(allowedTags.map((t) => t.toLowerCase()));
7
8
  const propsOptions = {
8
9
  allowedLinkPrefixes,
9
10
  allowedImagePrefixes,
@@ -13,11 +14,17 @@ export default defineComarkPlugin((options = {}) => {
13
14
  };
14
15
  return {
15
16
  name: 'security',
16
- post(state) {
17
- visit(state.tree, (node) => typeof node !== 'string' && node[0] !== null, (node) => {
17
+ async post(state) {
18
+ await visitAsync(state.tree, (node) => typeof node !== 'string' && node[0] !== null, async (node) => {
18
19
  const element = node;
19
- // return false to remove the node from the tree
20
- if (dropSet.has(element[0].toLowerCase())) {
20
+ const tagName = element[0].toLowerCase();
21
+ const isBlocked = dropSet.has(tagName);
22
+ const isNotAllowed = allowSet.size > 0 && !allowSet.has(tagName);
23
+ if (isNotAllowed || isBlocked) {
24
+ if (typeof tagFallback === 'function') {
25
+ return await tagFallback(element);
26
+ }
27
+ // return false to remove the node from the tree
21
28
  return false;
22
29
  }
23
30
  const keys = Object.keys(element[1]);
@@ -4,6 +4,22 @@ import { parseBracketContent } from "../internal/parse/syntax/brackets.js";
4
4
  import { searchProps } from "../internal/parse/syntax/props.js";
5
5
  import { parseBlockParams } from "../internal/parse/syntax/block-params.js";
6
6
  import { parseYaml } from "../internal/yaml.js";
7
+ /**
8
+ * A component name must start with a letter or `$`, followed by word chars,
9
+ * `$` or `-`. Mirrors the block name grammar (`RE_BLOCK_NAME = /^[a-z$]/i`).
10
+ */
11
+ const RE_COMPONENT_NAME = /^[a-z$][\w$-]*/i;
12
+ /**
13
+ * Whether `name` begins with a syntactically valid component name.
14
+ *
15
+ * This prevents sequences such as `:8100` or `::30` from being treated as
16
+ * components — a purely numeric name is not a valid component and would
17
+ * otherwise produce invalid output like `createElement('8100')` (inline) or
18
+ * throw `Invalid block params` (block).
19
+ */
20
+ function isValidComponentName(name) {
21
+ return RE_COMPONENT_NAME.test(name);
22
+ }
7
23
  // #region Block component plugin (`::name` and `::name ... ::`)
8
24
  const blockYamlLines = {
9
25
  '---': '---',
@@ -18,7 +34,7 @@ const markdownItComarkBlock = (md) => {
18
34
  const marker_char = marker_str.charCodeAt(0);
19
35
  md.block.ruler.before('fence', 'comark_block_shorthand', function comark_block_shorthand(state, startLine, _endLine, silent) {
20
36
  const line = state.src.slice(state.bMarks[startLine] + state.tShift[startLine], state.eMarks[startLine]);
21
- if (!/^:\w/.test(line))
37
+ if (line[0] !== ':' || !isValidComponentName(line.slice(1)))
22
38
  return false;
23
39
  const { name, content, props, remaining } = parseBlockParams(line.slice(1));
24
40
  // If there's unparsed remaining content, treat it as inline component in a paragraph
@@ -78,6 +94,11 @@ const markdownItComarkBlock = (md) => {
78
94
  if (marker_count < min_markers)
79
95
  return false;
80
96
  const markup = state.src.slice(start, pos);
97
+ // Bail out (plain text) on an invalid name instead of letting
98
+ // parseBlockParams throw on e.g. `::8100`.
99
+ const nameStart = state.skipSpaces(pos);
100
+ if (nameStart < max && !isValidComponentName(state.src.slice(nameStart, max)))
101
+ return false;
81
102
  const params = parseBlockParams(state.src.slice(pos, max));
82
103
  if (!params.name)
83
104
  return false;
@@ -383,10 +404,12 @@ const markdownItInlineComponent = (md) => {
383
404
  // Empty name
384
405
  if (nameEnd <= start + 1)
385
406
  return false;
407
+ const name = state.src.slice(start + 1, nameEnd);
408
+ if (!isValidComponentName(name))
409
+ return false;
386
410
  state.pos = index;
387
411
  if (silent)
388
412
  return true;
389
- const name = state.src.slice(start + 1, nameEnd);
390
413
  if (contentStart !== -1) {
391
414
  state.push('mdc_inline_component', name, 1);
392
415
  const oldPos = state.pos;
package/dist/render.d.ts CHANGED
@@ -8,7 +8,9 @@ export { resolveAttributes, resolveAttribute } from './internal/stringify/attrib
8
8
  * @param context - The context of the renderer
9
9
  * @returns The string representation of the Comark tree
10
10
  */
11
- export declare function render(tree: ComarkTree, context?: RenderOptions): Promise<string>;
11
+ export declare function render(tree: ComarkTree | {
12
+ nodes: ComarkTree['nodes'];
13
+ }, context?: RenderOptions): Promise<string>;
12
14
  /**
13
15
  * Render Comark tree to markdown
14
16
  *
@@ -16,4 +18,6 @@ export declare function render(tree: ComarkTree, context?: RenderOptions): Promi
16
18
  * @param options - Optional rendering options
17
19
  * @returns The markdown string with optional frontmatter
18
20
  */
19
- export declare function renderMarkdown(tree: ComarkTree, options?: RenderMarkdownOptions): Promise<string>;
21
+ export declare function renderMarkdown(tree: ComarkTree | {
22
+ nodes: ComarkTree['nodes'];
23
+ }, options?: RenderMarkdownOptions): Promise<string>;
package/dist/render.js CHANGED
@@ -11,7 +11,7 @@ export { resolveAttributes, resolveAttribute } from "./internal/stringify/attrib
11
11
  * @returns The string representation of the Comark tree
12
12
  */
13
13
  export async function render(tree, context = {}) {
14
- const state = createState({ ...context, tree, handlers: context.components });
14
+ const state = createState({ ...context, tree: tree, handlers: context.components });
15
15
  let result = '';
16
16
  for (const child of tree.nodes) {
17
17
  result += await one(child, state);
@@ -27,5 +27,5 @@ export async function render(tree, context = {}) {
27
27
  */
28
28
  export async function renderMarkdown(tree, options) {
29
29
  const content = await render(tree, { format: 'markdown/comark', ...options });
30
- return renderFrontmatter(tree.frontmatter, content, options?.frontmatterOptions);
30
+ return renderFrontmatter(tree.frontmatter || {}, content, options?.frontmatterOptions);
31
31
  }
package/dist/types.d.ts CHANGED
@@ -339,6 +339,11 @@ export interface ParseOptions<TPlugins extends readonly ComarkPlugin<any, any>[]
339
339
  * // With html: false — HTML tags are left as raw text / ignored
340
340
  */
341
341
  html?: boolean;
342
+ /**
343
+ * Set `false` to disable autoconvert URL-like text to links.
344
+ * @default true
345
+ */
346
+ linkify?: boolean;
342
347
  /**
343
348
  * Additional plugins to use
344
349
  * @default []
@@ -1,4 +1,5 @@
1
1
  import type { ComarkNode, ComarkTree } from 'comark';
2
+ type VisitResult = ComarkNode | false | undefined | void;
2
3
  /**
3
4
  * Get the text content of a Comark node
4
5
  *
@@ -17,7 +18,8 @@ export declare function textContent(node: ComarkNode, options?: {
17
18
  * @param checker - A function that checks if a node should be visited
18
19
  * @param visitor - A function that visits a node
19
20
  */
20
- export declare function visit(tree: ComarkTree, checker: (node: ComarkNode) => boolean, visitor: (node: ComarkNode) => ComarkNode | false | undefined | void): void;
21
+ export declare function visit(tree: ComarkTree, checker: (node: ComarkNode) => boolean, visitor: (node: ComarkNode) => VisitResult): void;
22
+ export declare function visitAsync(tree: ComarkTree, checker: (node: ComarkNode) => boolean, visitor: (node: ComarkNode) => Promise<VisitResult> | VisitResult): Promise<void>;
21
23
  export declare function indent(text: string, { ignoreFirstLine, level, width }?: {
22
24
  ignoreFirstLine?: boolean;
23
25
  level?: number;
@@ -22,20 +22,11 @@ export function textContent(node, options = {}) {
22
22
  }
23
23
  return out;
24
24
  }
25
- /**
26
- * Visit a Comark tree and apply a visitor function to each node
27
- *
28
- * @param tree - The Comark tree
29
- * @param checker - A function that checks if a node should be visited
30
- * @param visitor - A function that visits a node
31
- */
32
- export function visit(tree, checker,
33
- // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
34
- visitor) {
35
- function walk(node, parent, index) {
25
+ function* walkGenerator(tree, checker) {
26
+ function* walk(node, parent, index) {
36
27
  let currentNode = node;
37
28
  if (checker(node)) {
38
- const res = visitor(node);
29
+ const res = yield node;
39
30
  if (res === false) {
40
31
  // remove the node from the parent
41
32
  ;
@@ -52,7 +43,7 @@ visitor) {
52
43
  // Use a while loop to handle removals correctly - don't increment if node was removed
53
44
  let i = 2;
54
45
  while (i < currentNode.length) {
55
- const childRemoved = walk(currentNode[i], currentNode, i);
46
+ const childRemoved = yield* walk(currentNode[i], currentNode, i);
56
47
  if (childRemoved) {
57
48
  // If removed, i stays the same (next node is now at this index)
58
49
  continue;
@@ -65,7 +56,7 @@ visitor) {
65
56
  // Use a while loop to handle removals correctly - don't increment if node was removed
66
57
  let i = 0;
67
58
  while (i < tree.nodes.length) {
68
- const removed = walk(tree.nodes[i], tree.nodes, i);
59
+ const removed = yield* walk(tree.nodes[i], tree.nodes, i);
69
60
  if (removed) {
70
61
  // If removed, i stays the same (next node is now at this index)
71
62
  continue;
@@ -73,6 +64,31 @@ visitor) {
73
64
  i += 1;
74
65
  }
75
66
  }
67
+ /**
68
+ * Visit a Comark tree and apply a visitor function to each node
69
+ *
70
+ * @param tree - The Comark tree
71
+ * @param checker - A function that checks if a node should be visited
72
+ * @param visitor - A function that visits a node
73
+ */
74
+ export function visit(tree, checker,
75
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
76
+ visitor) {
77
+ const iterator = walkGenerator(tree, checker);
78
+ let step = iterator.next();
79
+ while (!step.done) {
80
+ const res = visitor(step.value);
81
+ step = iterator.next(res);
82
+ }
83
+ }
84
+ export async function visitAsync(tree, checker, visitor) {
85
+ const iterator = walkGenerator(tree, checker);
86
+ let step = iterator.next();
87
+ while (!step.done) {
88
+ const res = await visitor(step.value);
89
+ step = iterator.next(res);
90
+ }
91
+ }
76
92
  // #region String Utils
77
93
  export function indent(text, { ignoreFirstLine = false, level = 1, width } = {}) {
78
94
  const pad = width ? ' '.repeat(width) : ' '.repeat(level);
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "comark",
3
- "version": "0.4.0",
4
- "description": "Components in Markdown (Comark) parser with streaming support for Vue, React, Svelte and HTML",
3
+ "version": "0.5.0",
4
+ "description": "Components in Markdown (Comark) parser with streaming support for Vue, React, Svelte, Angular and HTML",
5
5
  "keywords": [
6
+ "angular",
6
7
  "markdown",
7
8
  "mdc",
8
9
  "parser",
9
10
  "react",
10
11
  "streaming",
12
+ "svelte",
11
13
  "vue"
12
14
  ],
13
15
  "homepage": "https://comark.dev",