comark 0.3.2 → 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 (78) 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/renderer/dom.d.ts +1 -0
  9. package/dist/devtools/renderer/dom.js +1 -0
  10. package/dist/devtools/renderer/index.d.ts +2 -0
  11. package/dist/devtools/renderer/index.js +2 -0
  12. package/dist/devtools/renderer/output.d.ts +1 -0
  13. package/dist/devtools/renderer/output.js +1 -0
  14. package/dist/devtools/renderer/panel.d.ts +1 -0
  15. package/dist/devtools/renderer/panel.js +1 -0
  16. package/dist/devtools/renderer/styles.d.ts +1 -0
  17. package/dist/devtools/renderer/styles.js +1 -0
  18. package/dist/devtools/renderer/theme.d.ts +1 -0
  19. package/dist/devtools/renderer/theme.js +1 -0
  20. package/dist/devtools/types.d.ts +1 -0
  21. package/dist/devtools/types.js +1 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +2 -0
  24. package/dist/internal/parse/auto-close/index.js +96 -31
  25. package/dist/internal/parse/auto-unwrap.js +5 -1
  26. package/dist/internal/parse/html/html_block_rule.js +9 -15
  27. package/dist/internal/parse/html/index.d.ts +1 -0
  28. package/dist/internal/parse/html/index.js +1 -1
  29. package/dist/internal/parse/token-processor.js +70 -32
  30. package/dist/internal/stringify/attributes.d.ts +8 -1
  31. package/dist/internal/stringify/attributes.js +53 -0
  32. package/dist/internal/stringify/handlers/blockquote.js +17 -0
  33. package/dist/internal/stringify/handlers/heading.js +6 -1
  34. package/dist/internal/stringify/handlers/html.js +8 -2
  35. package/dist/internal/stringify/handlers/li.js +19 -9
  36. package/dist/internal/stringify/handlers/mdc.js +1 -1
  37. package/dist/internal/stringify/handlers/ol.js +15 -2
  38. package/dist/internal/stringify/handlers/p.js +4 -0
  39. package/dist/internal/stringify/handlers/pre.js +11 -2
  40. package/dist/internal/stringify/handlers/table.js +7 -0
  41. package/dist/internal/stringify/handlers/template.js +4 -1
  42. package/dist/internal/stringify/handlers/ul.js +11 -1
  43. package/dist/internal/stringify/state.js +13 -1
  44. package/dist/parse.d.ts +4 -4
  45. package/dist/parse.js +7 -3
  46. package/dist/plugins/alert.d.ts +1 -1
  47. package/dist/plugins/binding.d.ts +1 -1
  48. package/dist/plugins/breaks.d.ts +1 -1
  49. package/dist/plugins/emoji.d.ts +1 -1
  50. package/dist/plugins/footnotes.d.ts +1 -1
  51. package/dist/plugins/headings.d.ts +19 -8
  52. package/dist/plugins/headings.js +25 -15
  53. package/dist/plugins/highlight.d.ts +1 -1
  54. package/dist/plugins/highlight.js +4 -2
  55. package/dist/plugins/json-render.d.ts +1 -1
  56. package/dist/plugins/math.d.ts +1 -1
  57. package/dist/plugins/mermaid.d.ts +1 -1
  58. package/dist/plugins/punctuation.d.ts +1 -1
  59. package/dist/plugins/security.d.ts +12 -1
  60. package/dist/plugins/security.js +13 -6
  61. package/dist/plugins/summary.d.ts +4 -1
  62. package/dist/plugins/syntax.d.ts +1 -1
  63. package/dist/plugins/syntax.js +95 -36
  64. package/dist/plugins/task-list.d.ts +1 -1
  65. package/dist/plugins/toc.d.ts +3 -1
  66. package/dist/render.d.ts +6 -2
  67. package/dist/render.js +2 -2
  68. package/dist/types.d.ts +61 -12
  69. package/dist/utils/helpers.d.ts +16 -4
  70. package/dist/utils/helpers.js +15 -3
  71. package/dist/utils/index.d.ts +3 -1
  72. package/dist/utils/index.js +30 -14
  73. package/package.json +6 -3
  74. package/skills/comark/references/rendering-svelte.md +51 -7
  75. package/dist/internal/stringify/indent.d.ts +0 -1
  76. package/dist/internal/stringify/indent.js +0 -1
  77. package/dist/vite.d.ts +0 -1
  78. package/dist/vite.js +0 -1
@@ -1,4 +1,6 @@
1
1
  import { htmlToComarkNodes, parseInlineHtmlTag } from "./html/index.js";
2
+ // `::tag` components that should fold into a single same-tagged child.
3
+ const WRAPPER_TAGS = new Set(['ul', 'ol', 'table', 'blockquote', 'pre']);
2
4
  // Mapping from token types to tag names
3
5
  const BLOCK_TAG_MAP = {
4
6
  blockquote_open: 'blockquote',
@@ -17,7 +19,8 @@ const INLINE_TAG_MAP = {
17
19
  strong_open: 'strong',
18
20
  em_open: 'em',
19
21
  s_open: 'del',
20
- sub_open: 'del',
22
+ sub_open: 'sub',
23
+ sup_open: 'sup',
21
24
  };
22
25
  // ─── main entry point ───────────────────────────────────────────────────────
23
26
  /**
@@ -33,6 +36,13 @@ export function marmdownItTokensToComarkTree(tokens, options = { startLine: 0, p
33
36
  let i = 0;
34
37
  let endLine = options.startLine;
35
38
  while (i < tokens.length) {
39
+ const token = tokens[i];
40
+ if (token.type === 'html_block') {
41
+ const result = processHtmlBlockTokens(tokens, i);
42
+ nodes.push(...result.nodes);
43
+ i = result.nextIndex;
44
+ continue;
45
+ }
36
46
  const result = processBlockToken(tokens, i, false, state);
37
47
  if (result.node) {
38
48
  if (options.preservePositions) {
@@ -54,6 +64,15 @@ export function marmdownItTokensToComarkTree(tokens, options = { startLine: 0, p
54
64
  }
55
65
  return nodes;
56
66
  }
67
+ /**
68
+ * Convert an html_block token into Comark nodes. The whole HTML payload is
69
+ * parsed once by htmlparser2; text inside is preserved verbatim (no markdown
70
+ * re-parsing — CommonMark default).
71
+ */
72
+ function processHtmlBlockTokens(tokens, startIndex) {
73
+ const content = typeof tokens[startIndex]?.content === 'string' ? tokens[startIndex].content : '';
74
+ return { nodes: htmlToComarkNodes(content), nextIndex: startIndex + 1 };
75
+ }
57
76
  /**
58
77
  * Extract and process attributes from a token's attrs array
59
78
  */
@@ -72,11 +91,7 @@ function processAttributes(attrsArray, options = {}) {
72
91
  continue;
73
92
  }
74
93
  // Handle boolean attributes: {bool} -> {":bool": "true"}
75
- if (handleBoolean &&
76
- !key.startsWith(':') &&
77
- !key.startsWith('#') &&
78
- !key.startsWith('.') &&
79
- (!value || value === 'true' || value === '')) {
94
+ if (handleBoolean && !key.startsWith(':') && !key.startsWith('#') && !key.startsWith('.') && value === 'true') {
80
95
  attrs[`:${key}`] = 'true';
81
96
  continue;
82
97
  }
@@ -222,22 +237,12 @@ function processBlockToken(tokens, startIndex, insideNestedContext = false, stat
222
237
  if (token.type === 'hr') {
223
238
  return { node: ['hr', {}], nextIndex: startIndex + 1 };
224
239
  }
225
- // html_block is now handled upstream (in marmdownItTokensToComarkTree /
240
+ // html_block is normally handled upstream (in marmdownItTokensToComarkTree /
226
241
  // processBlockChildren / processBlockChildrenWithSlots) before reaching here.
227
- // This branch is kept as a safety fallback.
242
+ // Safety fallback when it slips through.
228
243
  if (token.type === 'html_block') {
229
- const content = token.content?.trim() || '';
230
- if (content.startsWith('<!--')) {
231
- const inner = content.endsWith('-->') ? content.slice(4, -3) : content.slice(4);
232
- return { node: [null, {}, inner], nextIndex: startIndex + 1 };
233
- }
234
- const children = processBlockChildren(tokens, startIndex + 1, 'html_block_close', false, false, false, state);
235
- const [node1] = htmlToComarkNodes(content);
236
- if (!node1) {
237
- return { node: null, nextIndex: startIndex + 1 };
238
- }
239
- const node = [node1[0], node1[1], ...children.nodes];
240
- return { node, nextIndex: children.nextIndex + 1 };
244
+ const result = processHtmlBlockTokens(tokens, startIndex);
245
+ return { node: result.nodes[0] ?? null, nextIndex: result.nextIndex };
241
246
  }
242
247
  // Handle Comark block components (e.g., ::component ... ::)
243
248
  if (token.type === 'mdc_block_open') {
@@ -245,6 +250,20 @@ function processBlockToken(tokens, startIndex, insideNestedContext = false, stat
245
250
  const attrs = processAttributes(token.attrs);
246
251
  // Process children until mdc_block_close, handling slots (#slotname)
247
252
  const children = processBlockChildrenWithSlots(tokens, startIndex + 1, 'mdc_block_close', state);
253
+ // `::ul`/`::ol`/`::table`/`::blockquote`/`::pre` wrapping a single same-tag
254
+ // child collapses into a single element with the wrapper's attrs (outer wins).
255
+ if (WRAPPER_TAGS.has(componentName) &&
256
+ children.nodes.length === 1 &&
257
+ Array.isArray(children.nodes[0]) &&
258
+ children.nodes[0][0] === componentName) {
259
+ const inner = children.nodes[0];
260
+ const innerAttrs = inner[1];
261
+ const innerChildren = inner.slice(2);
262
+ return {
263
+ node: [componentName, { ...innerAttrs, ...attrs }, ...innerChildren],
264
+ nextIndex: children.nextIndex + 1,
265
+ };
266
+ }
248
267
  // Return the component even if it has no children (empty component like ::component\n::)
249
268
  return { node: [componentName, attrs, ...children.nodes], nextIndex: children.nextIndex + 1 };
250
269
  }
@@ -311,15 +330,17 @@ function processBlockToken(tokens, startIndex, insideNestedContext = false, stat
311
330
  if (token.type === 'heading_open') {
312
331
  const level = Number.parseInt(token.tag.replace('h', ''), 10);
313
332
  const headingTag = `h${level}`;
333
+ const userAttrs = processAttributes(token.attrs, { handleBoolean: false, handleJSON: false });
314
334
  // Process heading children with inHeading flag for Comark component handling
315
335
  const children = processBlockChildren(tokens, startIndex + 1, 'heading_close', true, true, insideNestedContext, state);
316
336
  if (children.nodes.length > 0) {
317
337
  // Always generate ID for all headings, no exceptions
318
338
  const textContent = extractTextContent(children.nodes);
319
339
  const headingId = uniqueSlug(slugify(textContent), level, state);
320
- // Always attach ID to the heading element itself
340
+ // Merge user-supplied attrs with the auto-generated id; user `id` wins.
341
+ const attrs = { id: headingId, ...userAttrs };
321
342
  return {
322
- node: [headingTag, { id: headingId }, ...children.nodes],
343
+ node: [headingTag, attrs, ...children.nodes],
323
344
  nextIndex: children.nextIndex + 1,
324
345
  };
325
346
  }
@@ -351,25 +372,26 @@ function processBlockChildrenWithSlots(tokens, startIndex, closeType, state) {
351
372
  const nodes = [];
352
373
  let i = startIndex;
353
374
  let currentSlotName = null;
375
+ let currentSlotAttrs = {};
354
376
  let currentSlotChildren = [];
355
377
  while (i < tokens.length && tokens[i].type !== closeType) {
356
378
  const token = tokens[i];
357
379
  // html_block can produce multiple nodes — handle before processBlockToken
358
380
  if (token.type === 'html_block') {
359
- const htmlNodes = htmlToComarkNodes(token.content);
381
+ const result = processHtmlBlockTokens(tokens, i);
360
382
  if (currentSlotName !== null) {
361
- currentSlotChildren.push(...htmlNodes);
383
+ currentSlotChildren.push(...result.nodes);
362
384
  }
363
385
  else {
364
- nodes.push(...htmlNodes);
386
+ nodes.push(...result.nodes);
365
387
  }
366
- i++;
388
+ i = result.nextIndex;
367
389
  continue;
368
390
  }
369
391
  // Check for slot marker: #slotname creates mdc_block_slot tokens
370
392
  if (token.type === 'mdc_block_slot') {
371
393
  // Extract slot name from token.attrs
372
- // The attrs array contains [["#slotname", ""]] for open, and null/empty for close
394
+ // The attrs array contains [["#slotname", ""], ...props] for open, and null/empty for close
373
395
  if (token.attrs && Array.isArray(token.attrs) && token.attrs.length > 0) {
374
396
  const firstAttr = token.attrs[0];
375
397
  if (Array.isArray(firstAttr) && firstAttr.length > 0) {
@@ -377,12 +399,21 @@ function processBlockChildrenWithSlots(tokens, startIndex, closeType, state) {
377
399
  // Remove the # prefix to get the slot name
378
400
  if (slotKey.startsWith('#')) {
379
401
  const slotName = slotKey.substring(1);
402
+ const slotAttrs = processAttributes(token.attrs.slice(1));
380
403
  // Save previous slot if any
381
404
  if (currentSlotName !== null && currentSlotChildren.length > 0) {
382
- nodes.push(['template', { name: currentSlotName }, ...currentSlotChildren]);
405
+ nodes.push([
406
+ 'template',
407
+ {
408
+ name: currentSlotName,
409
+ ...currentSlotAttrs,
410
+ },
411
+ ...currentSlotChildren,
412
+ ]);
383
413
  currentSlotChildren = [];
384
414
  }
385
415
  currentSlotName = slotName;
416
+ currentSlotAttrs = slotAttrs;
386
417
  i++;
387
418
  continue;
388
419
  }
@@ -409,7 +440,14 @@ function processBlockChildrenWithSlots(tokens, startIndex, closeType, state) {
409
440
  }
410
441
  // Save last slot if any
411
442
  if (currentSlotName !== null && currentSlotChildren.length > 0) {
412
- nodes.push(['template', { name: currentSlotName }, ...currentSlotChildren]);
443
+ nodes.push([
444
+ 'template',
445
+ {
446
+ name: currentSlotName,
447
+ ...currentSlotAttrs,
448
+ },
449
+ ...currentSlotChildren,
450
+ ]);
413
451
  }
414
452
  return { nodes, nextIndex: i };
415
453
  }
@@ -418,10 +456,10 @@ function processBlockChildren(tokens, startIndex, closeType, inlineOnly, inHeadi
418
456
  let i = startIndex;
419
457
  while (i < tokens.length && tokens[i].type !== closeType) {
420
458
  const token = tokens[i];
421
- // html_block can produce multiple nodes — handle before processBlockToken
422
459
  if (token.type === 'html_block') {
423
- nodes.push(...htmlToComarkNodes(token.content));
424
- i++;
460
+ const result = processHtmlBlockTokens(tokens, i);
461
+ nodes.push(...result.nodes);
462
+ i = result.nextIndex;
425
463
  continue;
426
464
  }
427
465
  if (token.type === 'inline') {
@@ -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
@@ -34,6 +34,13 @@ export declare function resolveAttributes(attrs: Record<string, unknown>, render
34
34
  * binding doesn't resolve.
35
35
  */
36
36
  export declare function resolveAttribute(attrs: Record<string, unknown>, renderData: NodeRenderData, key: string): unknown;
37
+ /**
38
+ * Filter implicit/auto-generated attrs that are encoded by the native
39
+ * markdown syntax and shouldn't echo back as `{attr=...}`. Used by the
40
+ * block stringifiers to decide whether a node has *user* attrs that must
41
+ * be preserved via the `::tag{...}` wrapper form.
42
+ */
43
+ export declare function userBlockAttrs(tag: string, attributes: Record<string, unknown>): Record<string, unknown>;
37
44
  /**
38
45
  * Convert attributes to a string of Comark attributes
39
46
  *
@@ -67,6 +67,59 @@ export function resolveAttribute(attrs, renderData, key) {
67
67
  }
68
68
  return attrs[key];
69
69
  }
70
+ // Implicit attributes the parser injects per tag — they're conveyed by the
71
+ // native markdown syntax (e.g. `as` becomes `> [!NOTE]`, `task-list-item`
72
+ // is implicit in `- [ ]`) so they should not echo back as user attrs.
73
+ const IMPLICIT_ATTRS = {
74
+ blockquote: { drop: ['as'] },
75
+ ol: { drop: ['start'] },
76
+ ul: { classBlocklist: ['contains-task-list'] },
77
+ li: { classBlocklist: ['task-list-item'] },
78
+ // `language`/`filename`/`highlights`/`meta` ride on the fence info string.
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'] },
84
+ };
85
+ /**
86
+ * Filter implicit/auto-generated attrs that are encoded by the native
87
+ * markdown syntax and shouldn't echo back as `{attr=...}`. Used by the
88
+ * block stringifiers to decide whether a node has *user* attrs that must
89
+ * be preserved via the `::tag{...}` wrapper form.
90
+ */
91
+ export function userBlockAttrs(tag, attributes) {
92
+ const rule = IMPLICIT_ATTRS[tag];
93
+ if (!rule)
94
+ return { ...attributes };
95
+ const result = {};
96
+ for (const [key, value] of Object.entries(attributes)) {
97
+ if (rule.drop?.includes(key))
98
+ continue;
99
+ if (key === 'class' && rule.classBlocklist && typeof value === 'string') {
100
+ const remaining = value
101
+ .split(/\s+/)
102
+ .filter((c) => c && !rule.classBlocklist.includes(c))
103
+ .join(' ');
104
+ if (remaining)
105
+ result[key] = remaining;
106
+ continue;
107
+ }
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.
112
+ const tokens = value.split(/\s+/);
113
+ let cutoff = tokens.findIndex((t) => t === '.');
114
+ const userClass = cutoff >= 0 ? tokens.slice(cutoff + 1).join(' ') : '';
115
+ if (userClass)
116
+ result[key] = userClass;
117
+ continue;
118
+ }
119
+ result[key] = value;
120
+ }
121
+ return result;
122
+ }
70
123
  /**
71
124
  * Convert attributes to a string of Comark attributes
72
125
  *
@@ -1,9 +1,26 @@
1
+ import { comarkAttributes, userBlockAttrs } from "../attributes.js";
1
2
  export async function blockquote(node, state) {
2
3
  const children = node.slice(2);
3
4
  let childResult = '';
4
5
  for (const child of children) {
5
6
  childResult += await state.one(child, state, node);
6
7
  }
8
+ const userAttrs = userBlockAttrs('blockquote', node[1]);
9
+ const attrs = comarkAttributes(userAttrs);
10
+ const hasBlockChildren = children.some((c) => Array.isArray(c));
11
+ // Multi-block content with attrs has no unambiguous inline form — round-trip
12
+ // via `::blockquote{attrs}` so the attrs aren't visually attached to one
13
+ // paragraph and parsers can recover the same AST.
14
+ if (attrs && hasBlockChildren) {
15
+ const content = childResult
16
+ .trim()
17
+ .split('\n')
18
+ .map((line) => (line ? `> ${line}` : '>'))
19
+ .join('\n');
20
+ return `::blockquote${attrs}\n${content}\n::` + state.context.blockSeparator;
21
+ }
22
+ if (attrs)
23
+ childResult = `${childResult.replace(/[ \t]+$/, '')} ${attrs}`;
7
24
  const content = childResult
8
25
  .trim()
9
26
  .split('\n')
@@ -1,7 +1,12 @@
1
+ import { comarkAttributes } from "../attributes.js";
1
2
  // h1, h2, h3, h4, h5, h6
2
3
  export async function heading(node, state) {
3
4
  const [tag] = node;
4
5
  const level = Number(tag.slice(1));
5
6
  const content = await state.flow(node, state);
6
- return '#'.repeat(level) + ' ' + content + state.context.blockSeparator;
7
+ // The auto-generated id is implicit in `# Heading` markdown — don't echo it.
8
+ const { id: _id, ...rest } = node[1];
9
+ const attrs = comarkAttributes(rest);
10
+ const suffix = attrs ? ` ${attrs}` : '';
11
+ return '#'.repeat(level) + ' ' + content + suffix + state.context.blockSeparator;
7
12
  }
@@ -82,12 +82,18 @@ export async function html(node, state, parent) {
82
82
  return `<${tag}${attrs} />` + (!parent && !isInline ? state.context.blockSeparator : '');
83
83
  }
84
84
  if (!oneLiner && content) {
85
- content = '\n' + paddNoneHtmlContent(content, state).trimEnd() + '\n';
85
+ content = '\n' + paddNoneHtmlContent(content, state, String(tag)).trimEnd() + '\n';
86
86
  }
87
87
  return `<${tag}${attrs}>${content}</${tag}>` + (!parent && !isInline ? state.context.blockSeparator : '');
88
88
  }
89
- function paddNoneHtmlContent(content, state) {
89
+ // Literal-content tags whose body must be rendered verbatim (no indentation
90
+ // re-flow). Matches the parser-side set so `<style>` / `<script>` etc. stay
91
+ // flush-left in the output, the way they were authored.
92
+ const LITERAL_CONTENT_TAGS = new Set(['code', 'kbd', 'pre', 'samp', 'script', 'style', 'textarea', 'var']);
93
+ function paddNoneHtmlContent(content, state, tag) {
90
94
  if (state.context.html) {
95
+ if (LITERAL_CONTENT_TAGS.has(tag.toLowerCase()))
96
+ return content;
91
97
  return indent(content);
92
98
  }
93
99
  return (content.trim().startsWith('<') ? '' : '') + content + (content.trim().endsWith('>') ? '' : '');
@@ -1,4 +1,5 @@
1
1
  import { indent } from "../../../utils/index.js";
2
+ import { comarkAttributes, userBlockAttrs } from "../attributes.js";
2
3
  // Block elements that need explicit indentation in list items.
3
4
  // Note: ol/ul are handled by their own handlers which manage indentation via listIndent context.
4
5
  const blockElements = new Set(['pre', 'blockquote', 'table']);
@@ -15,32 +16,41 @@ export async function li(node, state) {
15
16
  prefix += input[1].checked || input[1][':checked'] ? '[x] ' : '[ ] ';
16
17
  }
17
18
  const prefixWidth = prefix.length;
19
+ // Direct text children render sibling components inline.
20
+ const hasInlineContent = children.some((child) => typeof child === 'string');
18
21
  let result = '';
19
22
  for (const child of children) {
20
- const rendered = await state.one(child, state, node);
21
- if (result && Array.isArray(child)) {
22
- if (blockElements.has(child[0])) {
23
- // Block-level child: put on its own line and indent to align with list prefix
24
- 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 });
25
27
  result = result.trimEnd() + '\n' + indented.trimEnd() + '\n';
26
28
  continue;
27
29
  }
28
- if (child[0] === 'p') {
29
- const indented = indent(rendered, { width: prefixWidth });
30
+ if (result && tag === 'p') {
31
+ const indented = indent(await state.one(child, state, node), { width: prefixWidth });
30
32
  result = result.trimEnd() + '\n\n' + indented.trimEnd() + '\n';
31
33
  continue;
32
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
+ }
33
41
  }
34
- result += rendered;
42
+ result += await state.one(child, state, node);
35
43
  }
36
44
  result = result.trim();
45
+ const attrs = comarkAttributes(userBlockAttrs('li', node[1]));
46
+ const suffix = attrs ? ` ${attrs}` : '';
37
47
  if (!order) {
38
48
  result = escapeLeadingNumberDot(result);
39
49
  }
40
50
  if (order) {
41
51
  state.applyContext({ order: order + 1 });
42
52
  }
43
- return `${prefix}${result}\n`;
53
+ return `${prefix}${result}${suffix}\n`;
44
54
  }
45
55
  function escapeLeadingNumberDot(str) {
46
56
  if (str.length === 0)
@@ -5,7 +5,7 @@ import { html } from "./html.js";
5
5
  const INLINE_HTML_ELEMENTS = new Set(['a', 'strong', 'em', 'span']);
6
6
  export async function mdc(node, state, parent) {
7
7
  const [tag, attr, ...children] = node;
8
- const { $, ...attributes } = attr;
8
+ const { $: _, ...attributes } = attr;
9
9
  if (tag === 'table') {
10
10
  return html(node, state);
11
11
  }
@@ -1,18 +1,31 @@
1
1
  import { indent } from "../../../utils/index.js";
2
+ import { comarkAttributes, userBlockAttrs } from "../attributes.js";
2
3
  export async function ol(node, state) {
3
4
  const children = node.slice(2);
4
- 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 });
5
9
  let result = '';
6
10
  for (const child of children) {
7
11
  result += await state.one(child, state);
8
12
  }
9
13
  result = result.trim();
14
+ state.applyContext(revert);
15
+ // ol with user attrs round-trips via `::ol{attrs}\n1. …\n::` — the native
16
+ // markdown list syntax has no slot for list-level attrs.
17
+ const attrs = comarkAttributes(userBlockAttrs('ol', node[1]));
18
+ if (attrs) {
19
+ if (revert.list) {
20
+ return '\n' + indent(`::ol${attrs}\n${result}\n::`, { width: revert.listIndent || 2 });
21
+ }
22
+ return `::ol${attrs}\n${result}\n::` + state.context.blockSeparator;
23
+ }
10
24
  if (revert.list) {
11
25
  result = '\n' + indent(result, { width: revert.listIndent || 2 });
12
26
  }
13
27
  else {
14
28
  result = result + state.context.blockSeparator;
15
29
  }
16
- state.applyContext(revert);
17
30
  return result;
18
31
  }
@@ -1,9 +1,13 @@
1
+ import { comarkAttributes } from "../attributes.js";
1
2
  export async function p(node, state, parent) {
2
3
  const children = node.slice(2);
3
4
  let result = '';
4
5
  for (const child of children) {
5
6
  result += await state.one(child, state, node);
6
7
  }
8
+ const attrs = comarkAttributes(node[1]);
9
+ if (attrs)
10
+ result = `${result.replace(/[ \t]+$/, '')} ${attrs}`;
7
11
  if (parent?.[0] === 'li') {
8
12
  return result;
9
13
  }
@@ -1,4 +1,5 @@
1
1
  import { textContent } from "../../../utils/index.js";
2
+ import { comarkAttributes, userBlockAttrs } from "../attributes.js";
2
3
  export function pre(node, state) {
3
4
  const [_, attributes, ...children] = node;
4
5
  const codeClasses = children[0]?.[1]?.class;
@@ -13,8 +14,16 @@ export function pre(node, state) {
13
14
  const highlights = attributes.highlights ? ' {' + formatHighlights(attributes.highlights) + '}' : '';
14
15
  // Meta always has a leading space
15
16
  const meta = attributes.meta ? ' ' + attributes.meta : '';
16
- const result = '```' + language + filename + highlights + meta + '\n' + String(node[1]?.code || textContent(node)).trim() + '\n```';
17
- return result + state.context.blockSeparator;
17
+ const code = String(node[1]?.code || textContent(node)).trim();
18
+ const fence = code.includes('```') ? '~~~' : '```';
19
+ const fenceBlock = fence + language + filename + highlights + meta + '\n' + code + '\n' + fence;
20
+ // Extra user attrs that can't ride on the fence info string round-trip via
21
+ // `::pre{attrs}\n```…```\n::` — mirrors the wrapper form for ul/ol/table/blockquote.
22
+ const attrs = comarkAttributes(userBlockAttrs('pre', attributes));
23
+ if (attrs) {
24
+ return `::pre${attrs}\n${fenceBlock}\n::` + state.context.blockSeparator;
25
+ }
26
+ return fenceBlock + state.context.blockSeparator;
18
27
  }
19
28
  function formatHighlights(highlights) {
20
29
  if (highlights.length === 0)
@@ -1,3 +1,4 @@
1
+ import { comarkAttributes, userBlockAttrs } from "../attributes.js";
1
2
  // Helper function to extract alignment from style attribute
2
3
  function getAlignment(attributes) {
3
4
  const style = attributes.style;
@@ -158,6 +159,12 @@ export async function table(node, state) {
158
159
  result += '| ' + cellContents.join(' | ') + ' |\n';
159
160
  }
160
161
  // result already ends with \n, so we only need to add one more \n
162
+ // table with user attrs round-trips via `::table{attrs}\n<table>\n::` —
163
+ // GFM table syntax has no slot for table-level attrs.
164
+ const attrs = comarkAttributes(userBlockAttrs('table', node[1]));
165
+ if (attrs) {
166
+ return `::table${attrs}\n${result.trimEnd()}\n::\n\n`;
167
+ }
161
168
  return result + '\n';
162
169
  }
163
170
  export function thead(_node, _state) {
@@ -1,3 +1,4 @@
1
+ import { comarkAttributes } from "../attributes.js";
1
2
  // slot template
2
3
  export async function template(node, state, parent) {
3
4
  const [_, attrs] = node;
@@ -10,5 +11,7 @@ export async function template(node, state, parent) {
10
11
  return content + state.context.blockSeparator;
11
12
  }
12
13
  }
13
- return `#${attrs.name}\n${content}` + state.context.blockSeparator;
14
+ const { name: _name, $: _$, ...rest } = attrs;
15
+ const extraAttrs = comarkAttributes(rest);
16
+ return `#${attrs.name}${extraAttrs}\n${content}` + state.context.blockSeparator;
14
17
  }
@@ -1,4 +1,5 @@
1
1
  import { indent } from "../../../utils/index.js";
2
+ import { comarkAttributes, userBlockAttrs } from "../attributes.js";
2
3
  export async function ul(node, state) {
3
4
  const children = node.slice(2);
4
5
  const revert = state.applyContext({ list: true, order: false, listIndent: 2 });
@@ -7,12 +8,21 @@ export async function ul(node, state) {
7
8
  result += await state.one(child, state);
8
9
  }
9
10
  result = result.trim();
11
+ state.applyContext(revert);
12
+ // ul with user attrs round-trips via `::ul{attrs}\n- …\n::` — the native
13
+ // markdown list syntax has no slot for list-level attrs.
14
+ const attrs = comarkAttributes(userBlockAttrs('ul', node[1]));
15
+ if (attrs) {
16
+ if (revert.list) {
17
+ return '\n' + indent(`::ul${attrs}\n${result}\n::`, { width: revert.listIndent || 2 });
18
+ }
19
+ return `::ul${attrs}\n${result}\n::` + state.context.blockSeparator;
20
+ }
10
21
  if (revert.list) {
11
22
  result = '\n' + indent(result, { width: revert.listIndent || 2 });
12
23
  }
13
24
  else {
14
25
  result = result + state.context.blockSeparator;
15
26
  }
16
- state.applyContext(revert);
17
27
  return result;
18
28
  }
@@ -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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ComarkParseFn, ParseOptions, ComarkTree } from './types.ts';
1
+ import type { ComarkParseFn, ComarkPlugin, MergePluginFrontmatter, MergePluginMeta, ParseOptions, ResolvedFrontmatter, ResolvedMeta, ComarkTree } from './types.ts';
2
2
  export { parseFrontmatter } from './internal/frontmatter.ts';
3
3
  export { defineComarkPlugin } from './utils/helpers.ts';
4
4
  /**
@@ -30,7 +30,7 @@ export { defineComarkPlugin } from './utils/helpers.ts';
30
30
  * const parseNoHtml = createParse({ html: false })
31
31
  * ```
32
32
  */
33
- export declare function createParse(options?: ParseOptions): ComarkParseFn;
33
+ export declare function createParse<const TPlugins extends readonly ComarkPlugin<any, any>[] = []>(options?: ParseOptions<TPlugins>): ComarkParseFn<ResolvedMeta<MergePluginMeta<TPlugins>>, ResolvedFrontmatter<MergePluginFrontmatter<TPlugins>>>;
34
34
  /**
35
35
  * Parse Comark content from a string
36
36
  *
@@ -64,7 +64,7 @@ export declare function createParse(options?: ParseOptions): ComarkParseFn;
64
64
  * const tree2 = await parse(content, { autoUnwrap: false })
65
65
  * ```
66
66
  */
67
- export declare function parse(markdown: string, options?: ParseOptions): Promise<ComarkTree>;
67
+ export declare function parse<const TPlugins extends readonly ComarkPlugin<any, any>[] = []>(markdown: string, options?: ParseOptions<TPlugins>): Promise<ComarkTree<ResolvedMeta<MergePluginMeta<TPlugins>>, ResolvedFrontmatter<MergePluginFrontmatter<TPlugins>>>>;
68
68
  /**
69
69
  * Creates a serialized parser function for Comark content.
70
70
  * This is useful for parsing large files in a streaming manner.
@@ -80,4 +80,4 @@ export declare function parse(markdown: string, options?: ParseOptions): Promise
80
80
  * const tree = await parse(content)
81
81
  * console.log(tree.nodes)
82
82
  */
83
- export declare function createSerializedParse(options?: ParseOptions): ComarkParseFn;
83
+ export declare function createSerializedParse<const TPlugins extends readonly ComarkPlugin<any, any>[] = []>(options?: ParseOptions<TPlugins>): ComarkParseFn<ResolvedMeta<MergePluginMeta<TPlugins>>, ResolvedFrontmatter<MergePluginFrontmatter<TPlugins>>>;