comark 0.3.1 → 0.4.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 (93) hide show
  1. package/dist/internal/frontmatter.d.ts +1 -0
  2. package/dist/internal/frontmatter.js +4 -2
  3. package/dist/internal/parse/auto-close/index.js +69 -31
  4. package/dist/internal/parse/auto-close/table.js +12 -9
  5. package/dist/internal/parse/auto-unwrap.js +6 -10
  6. package/dist/internal/parse/html/html_block_rule.js +10 -16
  7. package/dist/internal/parse/html/html_inline_rule.js +3 -7
  8. package/dist/internal/parse/html/html_re.js +1 -1
  9. package/dist/internal/parse/html/index.d.ts +1 -0
  10. package/dist/internal/parse/html/index.js +15 -3
  11. package/dist/internal/parse/syntax/block-params.d.ts +9 -0
  12. package/dist/internal/parse/syntax/block-params.js +48 -0
  13. package/dist/internal/parse/syntax/brackets.d.ts +8 -0
  14. package/dist/internal/parse/syntax/brackets.js +20 -0
  15. package/dist/internal/parse/syntax/props.d.ts +5 -0
  16. package/dist/internal/parse/syntax/props.js +119 -0
  17. package/dist/internal/parse/token-processor.js +89 -50
  18. package/dist/internal/props-validation.js +4 -9
  19. package/dist/internal/stringify/attributes.d.ts +7 -0
  20. package/dist/internal/stringify/attributes.js +56 -1
  21. package/dist/internal/stringify/handlers/a.js +1 -3
  22. package/dist/internal/stringify/handlers/blockquote.js +19 -4
  23. package/dist/internal/stringify/handlers/code.js +1 -3
  24. package/dist/internal/stringify/handlers/emphesis.js +1 -3
  25. package/dist/internal/stringify/handlers/heading.js +6 -1
  26. package/dist/internal/stringify/handlers/html.js +34 -18
  27. package/dist/internal/stringify/handlers/img.js +1 -3
  28. package/dist/internal/stringify/handlers/li.js +18 -9
  29. package/dist/internal/stringify/handlers/mdc.js +3 -4
  30. package/dist/internal/stringify/handlers/ol.js +12 -2
  31. package/dist/internal/stringify/handlers/p.d.ts +1 -1
  32. package/dist/internal/stringify/handlers/p.js +8 -1
  33. package/dist/internal/stringify/handlers/pre.js +20 -14
  34. package/dist/internal/stringify/handlers/strong.js +1 -3
  35. package/dist/internal/stringify/handlers/table.js +14 -5
  36. package/dist/internal/stringify/handlers/template.js +5 -2
  37. package/dist/internal/stringify/handlers/ul.js +12 -2
  38. package/dist/internal/stringify/state.js +1 -1
  39. package/dist/internal/yaml.js +1 -1
  40. package/dist/parse.d.ts +4 -4
  41. package/dist/parse.js +20 -10
  42. package/dist/plugins/alert.d.ts +1 -1
  43. package/dist/plugins/alert.js +1 -1
  44. package/dist/plugins/binding.d.ts +1 -1
  45. package/dist/plugins/binding.js +1 -3
  46. package/dist/plugins/breaks.d.ts +1 -1
  47. package/dist/plugins/breaks.js +1 -1
  48. package/dist/plugins/emoji.d.ts +1 -1
  49. package/dist/plugins/emoji.js +8 -8
  50. package/dist/plugins/footnotes.d.ts +1 -1
  51. package/dist/plugins/footnotes.js +19 -13
  52. package/dist/plugins/headings.d.ts +19 -8
  53. package/dist/plugins/headings.js +27 -19
  54. package/dist/plugins/highlight.d.ts +2 -12
  55. package/dist/plugins/highlight.js +201 -103
  56. package/dist/plugins/json-render.d.ts +1 -1
  57. package/dist/plugins/json-render.js +5 -9
  58. package/dist/plugins/math.d.ts +1 -1
  59. package/dist/plugins/math.js +4 -6
  60. package/dist/plugins/mermaid.d.ts +1 -1
  61. package/dist/plugins/mermaid.js +6 -20
  62. package/dist/plugins/punctuation.d.ts +1 -1
  63. package/dist/plugins/punctuation.js +5 -6
  64. package/dist/plugins/security.d.ts +1 -1
  65. package/dist/plugins/security.js +2 -2
  66. package/dist/plugins/summary.d.ts +4 -1
  67. package/dist/plugins/syntax.d.ts +49 -0
  68. package/dist/plugins/syntax.js +558 -0
  69. package/dist/plugins/task-list.d.ts +2 -2
  70. package/dist/plugins/task-list.js +11 -8
  71. package/dist/plugins/toc.d.ts +3 -1
  72. package/dist/plugins/toc.js +1 -1
  73. package/dist/types.d.ts +57 -12
  74. package/dist/utils/comark.tmLanguage.d.ts +335 -0
  75. package/dist/utils/comark.tmLanguage.js +597 -0
  76. package/dist/utils/helpers.d.ts +16 -4
  77. package/dist/utils/helpers.js +16 -6
  78. package/dist/utils/index.d.ts +5 -0
  79. package/dist/utils/index.js +25 -3
  80. package/package.json +40 -40
  81. package/skills/comark/references/rendering-svelte.md +51 -7
  82. package/dist/internal/stringify/indent.d.ts +0 -5
  83. package/dist/internal/stringify/indent.js +0 -9
  84. package/dist/vite.d.ts +0 -1
  85. package/dist/vite.js +0 -1
  86. package/skills/skills/comark/AGENTS.md +0 -261
  87. package/skills/skills/comark/SKILL.md +0 -489
  88. package/skills/skills/comark/references/markdown-syntax.md +0 -599
  89. package/skills/skills/comark/references/parsing-ast.md +0 -378
  90. package/skills/skills/comark/references/rendering-react.md +0 -445
  91. package/skills/skills/comark/references/rendering-svelte.md +0 -453
  92. package/skills/skills/comark/references/rendering-vue.md +0 -462
  93. /package/skills/{skills/migrate-mdc-to-comark → migrate-mdc-to-comark}/SKILL.md +0 -0
@@ -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,17 +36,23 @@ 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) {
39
49
  for (let j = i; j < result.nextIndex; j++) {
40
50
  if (tokens[j].map && tokens[j].map[1]) {
41
- endLine = tokens[j].map[1]
42
- + options.startLine
43
- + (tokens[j].type?.endsWith('_close') ? 1 : 0);
51
+ endLine = tokens[j].map[1] + options.startLine + (tokens[j].type?.endsWith('_close') ? 1 : 0);
44
52
  }
45
53
  }
46
54
  if (!result.node[1].$) {
55
+ ;
47
56
  result.node[1].$ = {};
48
57
  }
49
58
  ;
@@ -55,6 +64,15 @@ export function marmdownItTokensToComarkTree(tokens, options = { startLine: 0, p
55
64
  }
56
65
  return nodes;
57
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
+ }
58
76
  /**
59
77
  * Extract and process attributes from a token's attrs array
60
78
  */
@@ -73,7 +91,7 @@ function processAttributes(attrsArray, options = {}) {
73
91
  continue;
74
92
  }
75
93
  // Handle boolean attributes: {bool} -> {":bool": "true"}
76
- if (handleBoolean && !key.startsWith(':') && !key.startsWith('#') && !key.startsWith('.') && (!value || value === 'true' || value === '')) {
94
+ if (handleBoolean && !key.startsWith(':') && !key.startsWith('#') && !key.startsWith('.') && value === 'true') {
77
95
  attrs[`:${key}`] = 'true';
78
96
  continue;
79
97
  }
@@ -140,7 +158,7 @@ function parseCodeblockInfo(info) {
140
158
  const trimmed = part.trim();
141
159
  if (trimmed.includes('-')) {
142
160
  // Range like "1-3"
143
- const [start, end] = trimmed.split('-').map(s => Number.parseInt(s.trim(), 10));
161
+ const [start, end] = trimmed.split('-').map((s) => Number.parseInt(s.trim(), 10));
144
162
  if (!Number.isNaN(start) && !Number.isNaN(end)) {
145
163
  for (let i = start; i <= end; i++) {
146
164
  highlights.push(i);
@@ -219,22 +237,12 @@ function processBlockToken(tokens, startIndex, insideNestedContext = false, stat
219
237
  if (token.type === 'hr') {
220
238
  return { node: ['hr', {}], nextIndex: startIndex + 1 };
221
239
  }
222
- // html_block is now handled upstream (in marmdownItTokensToComarkTree /
240
+ // html_block is normally handled upstream (in marmdownItTokensToComarkTree /
223
241
  // processBlockChildren / processBlockChildrenWithSlots) before reaching here.
224
- // This branch is kept as a safety fallback.
242
+ // Safety fallback when it slips through.
225
243
  if (token.type === 'html_block') {
226
- const content = token.content?.trim() || '';
227
- if (content.startsWith('<!--')) {
228
- const inner = content.endsWith('-->') ? content.slice(4, -3) : content.slice(4);
229
- return { node: [null, {}, inner], nextIndex: startIndex + 1 };
230
- }
231
- const children = processBlockChildren(tokens, startIndex + 1, 'html_block_close', false, false, false, state);
232
- const [node1] = htmlToComarkNodes(content);
233
- if (!node1) {
234
- return { node: null, nextIndex: startIndex + 1 };
235
- }
236
- const node = [node1[0], node1[1], ...children.nodes];
237
- return { node, nextIndex: children.nextIndex + 1 };
244
+ const result = processHtmlBlockTokens(tokens, startIndex);
245
+ return { node: result.nodes[0] ?? null, nextIndex: result.nextIndex };
238
246
  }
239
247
  // Handle Comark block components (e.g., ::component ... ::)
240
248
  if (token.type === 'mdc_block_open') {
@@ -242,6 +250,20 @@ function processBlockToken(tokens, startIndex, insideNestedContext = false, stat
242
250
  const attrs = processAttributes(token.attrs);
243
251
  // Process children until mdc_block_close, handling slots (#slotname)
244
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
+ }
245
267
  // Return the component even if it has no children (empty component like ::component\n::)
246
268
  return { node: [componentName, attrs, ...children.nodes], nextIndex: children.nextIndex + 1 };
247
269
  }
@@ -271,7 +293,10 @@ function processBlockToken(tokens, startIndex, insideNestedContext = false, stat
271
293
  return { node: [componentName, attrs, ...children], nextIndex: nextIndex };
272
294
  }
273
295
  if (token.type === 'math_block') {
274
- return { node: ['math', { class: 'math block', content: token.content }, token.content], nextIndex: startIndex + 1 };
296
+ return {
297
+ node: ['math', { class: 'math block', content: token.content }, token.content],
298
+ nextIndex: startIndex + 1,
299
+ };
275
300
  }
276
301
  if (token.type === 'fence' || token.type === 'fenced_code_block' || token.type === 'code_block') {
277
302
  const content = token.content || '';
@@ -305,14 +330,19 @@ function processBlockToken(tokens, startIndex, insideNestedContext = false, stat
305
330
  if (token.type === 'heading_open') {
306
331
  const level = Number.parseInt(token.tag.replace('h', ''), 10);
307
332
  const headingTag = `h${level}`;
333
+ const userAttrs = processAttributes(token.attrs, { handleBoolean: false, handleJSON: false });
308
334
  // Process heading children with inHeading flag for Comark component handling
309
335
  const children = processBlockChildren(tokens, startIndex + 1, 'heading_close', true, true, insideNestedContext, state);
310
336
  if (children.nodes.length > 0) {
311
337
  // Always generate ID for all headings, no exceptions
312
338
  const textContent = extractTextContent(children.nodes);
313
339
  const headingId = uniqueSlug(slugify(textContent), level, state);
314
- // Always attach ID to the heading element itself
315
- return { node: [headingTag, { id: headingId }, ...children.nodes], nextIndex: children.nextIndex + 1 };
340
+ // Merge user-supplied attrs with the auto-generated id; user `id` wins.
341
+ const attrs = { id: headingId, ...userAttrs };
342
+ return {
343
+ node: [headingTag, attrs, ...children.nodes],
344
+ nextIndex: children.nextIndex + 1,
345
+ };
316
346
  }
317
347
  return { node: null, nextIndex: children.nextIndex + 1 };
318
348
  }
@@ -320,19 +350,8 @@ function processBlockToken(tokens, startIndex, insideNestedContext = false, stat
320
350
  if (token.type === 'list_item_open') {
321
351
  const attrs = processAttributes(token.attrs, { handleBoolean: false, handleJSON: false });
322
352
  const children = processBlockChildren(tokens, startIndex + 1, 'list_item_close', false, false, true, state);
323
- // Unwrap paragraphs in list items
324
- const unwrapped = [];
325
- for (const child of children.nodes) {
326
- if (Array.isArray(child) && child[0] === 'p') {
327
- // Unwrap paragraph, add its children directly
328
- unwrapped.push(...child.slice(2));
329
- }
330
- else {
331
- unwrapped.push(child);
332
- }
333
- }
334
- if (unwrapped.length > 0) {
335
- return { node: ['li', attrs, ...unwrapped], nextIndex: children.nextIndex + 1 };
353
+ if (children.nodes.length > 0) {
354
+ return { node: ['li', attrs, ...children.nodes], nextIndex: children.nextIndex + 1 };
336
355
  }
337
356
  return { node: null, nextIndex: children.nextIndex + 1 };
338
357
  }
@@ -353,25 +372,26 @@ function processBlockChildrenWithSlots(tokens, startIndex, closeType, state) {
353
372
  const nodes = [];
354
373
  let i = startIndex;
355
374
  let currentSlotName = null;
375
+ let currentSlotAttrs = {};
356
376
  let currentSlotChildren = [];
357
377
  while (i < tokens.length && tokens[i].type !== closeType) {
358
378
  const token = tokens[i];
359
379
  // html_block can produce multiple nodes — handle before processBlockToken
360
380
  if (token.type === 'html_block') {
361
- const htmlNodes = htmlToComarkNodes(token.content);
381
+ const result = processHtmlBlockTokens(tokens, i);
362
382
  if (currentSlotName !== null) {
363
- currentSlotChildren.push(...htmlNodes);
383
+ currentSlotChildren.push(...result.nodes);
364
384
  }
365
385
  else {
366
- nodes.push(...htmlNodes);
386
+ nodes.push(...result.nodes);
367
387
  }
368
- i++;
388
+ i = result.nextIndex;
369
389
  continue;
370
390
  }
371
391
  // Check for slot marker: #slotname creates mdc_block_slot tokens
372
392
  if (token.type === 'mdc_block_slot') {
373
393
  // Extract slot name from token.attrs
374
- // 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
375
395
  if (token.attrs && Array.isArray(token.attrs) && token.attrs.length > 0) {
376
396
  const firstAttr = token.attrs[0];
377
397
  if (Array.isArray(firstAttr) && firstAttr.length > 0) {
@@ -379,12 +399,21 @@ function processBlockChildrenWithSlots(tokens, startIndex, closeType, state) {
379
399
  // Remove the # prefix to get the slot name
380
400
  if (slotKey.startsWith('#')) {
381
401
  const slotName = slotKey.substring(1);
402
+ const slotAttrs = processAttributes(token.attrs.slice(1));
382
403
  // Save previous slot if any
383
404
  if (currentSlotName !== null && currentSlotChildren.length > 0) {
384
- nodes.push(['template', { name: currentSlotName }, ...currentSlotChildren]);
405
+ nodes.push([
406
+ 'template',
407
+ {
408
+ name: currentSlotName,
409
+ ...currentSlotAttrs,
410
+ },
411
+ ...currentSlotChildren,
412
+ ]);
385
413
  currentSlotChildren = [];
386
414
  }
387
415
  currentSlotName = slotName;
416
+ currentSlotAttrs = slotAttrs;
388
417
  i++;
389
418
  continue;
390
419
  }
@@ -411,7 +440,14 @@ function processBlockChildrenWithSlots(tokens, startIndex, closeType, state) {
411
440
  }
412
441
  // Save last slot if any
413
442
  if (currentSlotName !== null && currentSlotChildren.length > 0) {
414
- nodes.push(['template', { name: currentSlotName }, ...currentSlotChildren]);
443
+ nodes.push([
444
+ 'template',
445
+ {
446
+ name: currentSlotName,
447
+ ...currentSlotAttrs,
448
+ },
449
+ ...currentSlotChildren,
450
+ ]);
415
451
  }
416
452
  return { nodes, nextIndex: i };
417
453
  }
@@ -420,10 +456,10 @@ function processBlockChildren(tokens, startIndex, closeType, inlineOnly, inHeadi
420
456
  let i = startIndex;
421
457
  while (i < tokens.length && tokens[i].type !== closeType) {
422
458
  const token = tokens[i];
423
- // html_block can produce multiple nodes — handle before processBlockToken
424
459
  if (token.type === 'html_block') {
425
- nodes.push(...htmlToComarkNodes(token.content));
426
- i++;
460
+ const result = processHtmlBlockTokens(tokens, i);
461
+ nodes.push(...result.nodes);
462
+ i = result.nextIndex;
427
463
  continue;
428
464
  }
429
465
  if (token.type === 'inline') {
@@ -615,7 +651,7 @@ function processInlineToken(tokens, startIndex, inHeading = false) {
615
651
  return { node, nextIndex: j };
616
652
  }
617
653
  // Handle Comark inline span (e.g., [text]{attr})
618
- // @comark/markdown-it creates mdc_inline_span tokens, and props appear AFTER the close token
654
+ // The syntax plugin emits mdc_inline_span tokens, and props appear AFTER the close token
619
655
  if (token.type === 'mdc_inline_span' && token.nesting === 1) {
620
656
  const attrs = {};
621
657
  let i = startIndex + 1;
@@ -698,11 +734,11 @@ function processInlineToken(tokens, startIndex, inHeading = false) {
698
734
  else {
699
735
  // Self-closing component (nesting === 0)
700
736
  const attrs = {};
701
- // @comark/markdown-it stores attributes in a separate mdc_inline_props token
737
+ // The syntax plugin stores attributes in a separate mdc_inline_props token
702
738
  // that appears right after the component token
703
739
  const { attrs: componentAttrs, nextIndex: propsNextIndex } = extractAttributes(tokens, startIndex + 1, false);
704
740
  Object.assign(attrs, componentAttrs);
705
- // Extract attributes from token.attrs (fallback, though @comark/markdown-it uses mdc_inline_props)
741
+ // Extract attributes from token.attrs (fallback, though the syntax plugin uses mdc_inline_props)
706
742
  const fallbackAttrs = processAttributes(token.attrs, { handleBoolean: false });
707
743
  Object.assign(attrs, fallbackAttrs);
708
744
  // Return the component without any text children
@@ -734,7 +770,10 @@ function processInlineToken(tokens, startIndex, inHeading = false) {
734
770
  return { node: null, nextIndex };
735
771
  }
736
772
  if (token.type === 'math_inline') {
737
- return { node: ['math', { class: 'math inline', content: token.content }, token.content], nextIndex: startIndex + 1 };
773
+ return {
774
+ node: ['math', { class: 'math inline', content: token.content }, token.content],
775
+ nextIndex: startIndex + 1,
776
+ };
738
777
  }
739
778
  // Handle generic inline open/close pairs
740
779
  const tagName = INLINE_TAG_MAP[token.type];
@@ -1,10 +1,5 @@
1
- export const unsafeTags = [
2
- 'object',
3
- ];
4
- export const unsafeAttributes = [
5
- 'srcdoc',
6
- 'formaction',
7
- ];
1
+ export const unsafeTags = ['object'];
2
+ export const unsafeAttributes = ['srcdoc', 'formaction'];
8
3
  export const unsafeLinkPrefix = [
9
4
  'javascript:',
10
5
  'data:text/html',
@@ -44,7 +39,7 @@ function validateUrl(value, mode, options) {
44
39
  return value;
45
40
  }
46
41
  // Block known-unsafe protocols — hard floor, not overrideable by options
47
- if (unsafeLinkPrefix.some(prefix => url.href.toLowerCase().startsWith(prefix))) {
42
+ if (unsafeLinkPrefix.some((prefix) => url.href.toLowerCase().startsWith(prefix))) {
48
43
  return false;
49
44
  }
50
45
  // Block data: images when allowDataImages is false
@@ -62,7 +57,7 @@ function validateUrl(value, mode, options) {
62
57
  const allowedPrefixes = mode === 'link' ? allowedLinkPrefixes : allowedImagePrefixes;
63
58
  if (!allowedPrefixes.includes('*')) {
64
59
  const href = url.href.toLowerCase();
65
- const matchesPrefix = allowedPrefixes.some(prefix => href.startsWith(prefix.toLowerCase()));
60
+ const matchesPrefix = allowedPrefixes.some((prefix) => href.startsWith(prefix.toLowerCase()));
66
61
  if (!matchesPrefix) {
67
62
  if (defaultOrigin) {
68
63
  return rewriteToDefaultOrigin(urlSanitized, defaultOrigin);
@@ -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,58 @@ 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
+ ul: { classBlocklist: ['contains-task-list'] },
76
+ li: { classBlocklist: ['task-list-item'] },
77
+ // `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'] },
83
+ };
84
+ /**
85
+ * Filter implicit/auto-generated attrs that are encoded by the native
86
+ * markdown syntax and shouldn't echo back as `{attr=...}`. Used by the
87
+ * block stringifiers to decide whether a node has *user* attrs that must
88
+ * be preserved via the `::tag{...}` wrapper form.
89
+ */
90
+ export function userBlockAttrs(tag, attributes) {
91
+ const rule = IMPLICIT_ATTRS[tag];
92
+ if (!rule)
93
+ return { ...attributes };
94
+ const result = {};
95
+ for (const [key, value] of Object.entries(attributes)) {
96
+ if (rule.drop?.includes(key))
97
+ continue;
98
+ if (key === 'class' && rule.classBlocklist && typeof value === 'string') {
99
+ const remaining = value
100
+ .split(/\s+/)
101
+ .filter((c) => c && !rule.classBlocklist.includes(c))
102
+ .join(' ');
103
+ if (remaining)
104
+ result[key] = remaining;
105
+ continue;
106
+ }
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.
111
+ const tokens = value.split(/\s+/);
112
+ let cutoff = tokens.findIndex((t) => t === '.');
113
+ const userClass = cutoff >= 0 ? tokens.slice(cutoff + 1).join(' ') : '';
114
+ if (userClass)
115
+ result[key] = userClass;
116
+ continue;
117
+ }
118
+ result[key] = value;
119
+ }
120
+ return result;
121
+ }
70
122
  /**
71
123
  * Convert attributes to a string of Comark attributes
72
124
  *
@@ -83,7 +135,10 @@ export function comarkAttributes(attributes) {
83
135
  return `#${value}`;
84
136
  }
85
137
  if (key === 'class') {
86
- return value.split(' ').map(c => `.${c}`).join('');
138
+ return value
139
+ .split(' ')
140
+ .map((c) => `.${c}`)
141
+ .join('');
87
142
  }
88
143
  if (typeof value === 'object') {
89
144
  return `${key}="${JSON.stringify(value).replace(/"/g, '\\"')}"`;
@@ -3,9 +3,7 @@ import { comarkAttributes } from "../attributes.js";
3
3
  export async function a(node, state) {
4
4
  const [_, attrs] = node;
5
5
  const { href, ...rest } = attrs;
6
- const attrsString = Object.keys(rest).length > 0
7
- ? comarkAttributes(rest)
8
- : '';
6
+ const attrsString = Object.keys(rest).length > 0 ? comarkAttributes(rest) : '';
9
7
  const content = await state.flow(node, state);
10
8
  if (content === href && !attrsString) {
11
9
  return `<${href}>`;
@@ -1,18 +1,33 @@
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')
10
- .map(line => line ? `> ${line}` : '>')
27
+ .map((line) => (line ? `> ${line}` : '>'))
11
28
  .join('\n');
12
29
  if (node[1].as) {
13
- return `> [!${String(node[1].as).toUpperCase()}]\n`
14
- + content
15
- + state.context.blockSeparator;
30
+ return `> [!${String(node[1].as).toUpperCase()}]\n` + content + state.context.blockSeparator;
16
31
  }
17
32
  return content + state.context.blockSeparator;
18
33
  }
@@ -2,9 +2,7 @@ import { comarkAttributes } from "../attributes.js";
2
2
  import { textContent } from "../../../utils/index.js";
3
3
  export function code(node, _state) {
4
4
  const [_, attrs] = node;
5
- const attrsString = Object.keys(attrs).length > 0
6
- ? comarkAttributes(attrs)
7
- : '';
5
+ const attrsString = Object.keys(attrs).length > 0 ? comarkAttributes(attrs) : '';
8
6
  const content = textContent(node);
9
7
  const fence = content.includes('`') ? '``' : '`';
10
8
  return `${fence}${content}${fence}${attrsString}`;
@@ -6,8 +6,6 @@ export async function emphesis(node, state) {
6
6
  content += await state.one(child, state, node);
7
7
  }
8
8
  content = content.trim();
9
- const attrsString = Object.keys(attrs).length > 0
10
- ? comarkAttributes(attrs)
11
- : '';
9
+ const attrsString = Object.keys(attrs).length > 0 ? comarkAttributes(attrs) : '';
12
10
  return `*${content}*${attrsString}`;
13
11
  }
@@ -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
  }
@@ -1,9 +1,25 @@
1
1
  import { htmlAttributes } from "../attributes.js";
2
- import { indent } from "../indent.js";
2
+ import { indent } from "../../../utils/index.js";
3
3
  const textBlocks = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'td', 'th']);
4
4
  const selfCloseTags = new Set(['br', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
5
5
  const inlineTags = new Set(['strong', 'em', 'code', 'a', 'br', 'span', 'img']);
6
- const blockTags = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul', 'ol', 'blockquote', 'hr', 'table', 'td', 'th']);
6
+ const blockTags = new Set([
7
+ 'p',
8
+ 'h1',
9
+ 'h2',
10
+ 'h3',
11
+ 'h4',
12
+ 'h5',
13
+ 'h6',
14
+ 'li',
15
+ 'ul',
16
+ 'ol',
17
+ 'blockquote',
18
+ 'hr',
19
+ 'table',
20
+ 'td',
21
+ 'th',
22
+ ]);
7
23
  export async function html(node, state, parent) {
8
24
  const [tag, attr, ...children] = node;
9
25
  const { $ = {}, ...rawAttributes } = attr;
@@ -15,11 +31,9 @@ export async function html(node, state, parent) {
15
31
  // back to the raw (empty) attrs to avoid leaking parent props onto native
16
32
  // wrappers like `<p>` or `<ul>`.
17
33
  const rawHasAttrs = Object.keys(rawAttributes).length > 0;
18
- const attributes = state.context.html
19
- ? (rawHasAttrs ? state.renderData.props : rawAttributes)
20
- : rawAttributes;
21
- const hasOnlyTextChildren = children.every(child => typeof child === 'string' || inlineTags.has(String(child?.[0])));
22
- const hasTextSibling = children.some(child => typeof child === 'string');
34
+ const attributes = state.context.html ? (rawHasAttrs ? state.renderData.props : rawAttributes) : rawAttributes;
35
+ const hasOnlyTextChildren = children.every((child) => typeof child === 'string' || inlineTags.has(String(child?.[0])));
36
+ const hasTextSibling = children.some((child) => typeof child === 'string');
23
37
  const isBlock = textBlocks.has(String(tag));
24
38
  const isInline = inlineTags.has(String(tag)) && $.block === 0;
25
39
  let oneLiner = isBlock && hasOnlyTextChildren;
@@ -48,7 +62,8 @@ export async function html(node, state, parent) {
48
62
  for (let i = 0; i < children.length; i++) {
49
63
  const childContent = childrenContent[i];
50
64
  const child = children[i];
51
- const isBlock = typeof child !== 'string' && (blockTags.has(String(child?.[0])) || (!inlineTags.has(String(child?.[0])) && !hasTextSibling));
65
+ const isBlock = typeof child !== 'string' &&
66
+ (blockTags.has(String(child?.[0])) || (!inlineTags.has(String(child?.[0])) && !hasTextSibling));
52
67
  if (i > 0 && !isPrevBlock && isBlock) {
53
68
  content += state.context.blockSeparator;
54
69
  }
@@ -62,23 +77,24 @@ export async function html(node, state, parent) {
62
77
  if (revert) {
63
78
  state.applyContext(revert);
64
79
  }
65
- const attrs = Object.keys(attributes).length > 0
66
- ? ` ${htmlAttributes(attributes)}`
67
- : '';
80
+ const attrs = Object.keys(attributes).length > 0 ? ` ${htmlAttributes(attributes)}` : '';
68
81
  if (isSelfClose) {
69
82
  return `<${tag}${attrs} />` + (!parent && !isInline ? state.context.blockSeparator : '');
70
83
  }
71
84
  if (!oneLiner && content) {
72
- content = '\n' + paddNoneHtmlContent(content, state).trimEnd() + '\n';
85
+ content = '\n' + paddNoneHtmlContent(content, state, String(tag)).trimEnd() + '\n';
73
86
  }
74
- return `<${tag}${attrs}>${content}</${tag}>`
75
- + (!parent && !isInline ? state.context.blockSeparator : '');
87
+ return `<${tag}${attrs}>${content}</${tag}>` + (!parent && !isInline ? state.context.blockSeparator : '');
76
88
  }
77
- 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) {
78
94
  if (state.context.html) {
95
+ if (LITERAL_CONTENT_TAGS.has(tag.toLowerCase()))
96
+ return content;
79
97
  return indent(content);
80
98
  }
81
- return ((content.trim().startsWith('<') ? '' : '')
82
- + content
83
- + (content.trim().endsWith('>') ? '' : ''));
99
+ return (content.trim().startsWith('<') ? '' : '') + content + (content.trim().endsWith('>') ? '' : '');
84
100
  }
@@ -2,8 +2,6 @@ import { comarkAttributes } from "../attributes.js";
2
2
  export function img(node, _state) {
3
3
  const [_, attrs] = node;
4
4
  const { title, src, alt = '', ...rest } = attrs;
5
- const attrsString = Object.keys(rest).length > 0
6
- ? comarkAttributes(rest)
7
- : '';
5
+ const attrsString = Object.keys(rest).length > 0 ? comarkAttributes(rest) : '';
8
6
  return title ? `![${alt}](${src} "${title}")` : `![${alt}](${src})${attrsString}`;
9
7
  }
@@ -1,4 +1,5 @@
1
- import { indent } from "../indent.js";
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']);
@@ -18,23 +19,31 @@ export async function li(node, state) {
18
19
  let result = '';
19
20
  for (const child of children) {
20
21
  const rendered = await state.one(child, state, node);
21
- if (Array.isArray(child) && blockElements.has(child[0])) {
22
- // Block-level child: put on its own line and indent to align with list prefix
23
- const indented = indent(rendered, { width: prefixWidth });
24
- result = result.trimEnd() + '\n' + indented.trimEnd() + '\n';
25
- }
26
- else {
27
- result += rendered;
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 });
26
+ result = result.trimEnd() + '\n' + indented.trimEnd() + '\n';
27
+ continue;
28
+ }
29
+ if (child[0] === 'p') {
30
+ const indented = indent(rendered, { width: prefixWidth });
31
+ result = result.trimEnd() + '\n\n' + indented.trimEnd() + '\n';
32
+ continue;
33
+ }
28
34
  }
35
+ result += rendered;
29
36
  }
30
37
  result = result.trim();
38
+ const attrs = comarkAttributes(userBlockAttrs('li', node[1]));
39
+ const suffix = attrs ? ` ${attrs}` : '';
31
40
  if (!order) {
32
41
  result = escapeLeadingNumberDot(result);
33
42
  }
34
43
  if (order) {
35
44
  state.applyContext({ order: order + 1 });
36
45
  }
37
- return `${prefix}${result}\n`;
46
+ return `${prefix}${result}${suffix}\n`;
38
47
  }
39
48
  function escapeLeadingNumberDot(str) {
40
49
  if (str.length === 0)