comark 0.3.2 → 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 (57) hide show
  1. package/dist/internal/parse/auto-close/index.js +44 -18
  2. package/dist/internal/parse/auto-unwrap.js +5 -1
  3. package/dist/internal/parse/html/html_block_rule.js +9 -15
  4. package/dist/internal/parse/html/index.d.ts +1 -0
  5. package/dist/internal/parse/html/index.js +1 -1
  6. package/dist/internal/parse/token-processor.js +70 -32
  7. package/dist/internal/stringify/attributes.d.ts +7 -0
  8. package/dist/internal/stringify/attributes.js +52 -0
  9. package/dist/internal/stringify/handlers/blockquote.js +17 -0
  10. package/dist/internal/stringify/handlers/heading.js +6 -1
  11. package/dist/internal/stringify/handlers/html.js +8 -2
  12. package/dist/internal/stringify/handlers/li.js +4 -1
  13. package/dist/internal/stringify/handlers/mdc.js +1 -1
  14. package/dist/internal/stringify/handlers/ol.js +11 -1
  15. package/dist/internal/stringify/handlers/p.js +4 -0
  16. package/dist/internal/stringify/handlers/pre.js +11 -2
  17. package/dist/internal/stringify/handlers/table.js +7 -0
  18. package/dist/internal/stringify/handlers/template.js +4 -1
  19. package/dist/internal/stringify/handlers/ul.js +11 -1
  20. package/dist/parse.d.ts +4 -4
  21. package/dist/parse.js +6 -2
  22. package/dist/plugins/alert.d.ts +1 -1
  23. package/dist/plugins/binding.d.ts +1 -1
  24. package/dist/plugins/breaks.d.ts +1 -1
  25. package/dist/plugins/emoji.d.ts +1 -1
  26. package/dist/plugins/footnotes.d.ts +1 -1
  27. package/dist/plugins/headings.d.ts +19 -8
  28. package/dist/plugins/headings.js +25 -15
  29. package/dist/plugins/highlight.d.ts +1 -1
  30. package/dist/plugins/highlight.js +4 -1
  31. package/dist/plugins/json-render.d.ts +1 -1
  32. package/dist/plugins/math.d.ts +1 -1
  33. package/dist/plugins/mermaid.d.ts +1 -1
  34. package/dist/plugins/punctuation.d.ts +1 -1
  35. package/dist/plugins/security.d.ts +1 -1
  36. package/dist/plugins/summary.d.ts +4 -1
  37. package/dist/plugins/syntax.d.ts +1 -1
  38. package/dist/plugins/syntax.js +70 -34
  39. package/dist/plugins/task-list.d.ts +1 -1
  40. package/dist/plugins/toc.d.ts +3 -1
  41. package/dist/types.d.ts +56 -12
  42. package/dist/utils/helpers.d.ts +16 -4
  43. package/dist/utils/helpers.js +15 -3
  44. package/package.json +3 -2
  45. package/skills/comark/references/rendering-svelte.md +51 -7
  46. package/dist/devtools/index.d.ts +0 -1
  47. package/dist/devtools/index.js +0 -1
  48. package/dist/devtools/register.d.ts +0 -1
  49. package/dist/devtools/register.js +0 -1
  50. package/dist/devtools/registry.d.ts +0 -1
  51. package/dist/devtools/registry.js +0 -1
  52. package/dist/devtools/vite.d.ts +0 -1
  53. package/dist/devtools/vite.js +0 -1
  54. package/dist/internal/stringify/indent.d.ts +0 -1
  55. package/dist/internal/stringify/indent.js +0 -1
  56. package/dist/vite.d.ts +0 -1
  57. package/dist/vite.js +0 -1
@@ -19,10 +19,33 @@ export function autoCloseMarkdown(markdown) {
19
19
  let inFrontmatter = false;
20
20
  let inBlockMath = false;
21
21
  let tableStart = -1;
22
+ // Tag name when inside a raw-text HTML element (`<style>`, `<script>`,
23
+ // `<pre>`, `<textarea>`). Their bodies must be passed through verbatim —
24
+ // any `::root`/`**` markers there are CSS/JS/text, not Comark/markdown.
25
+ let inRawTextElement = null;
26
+ const RAW_TEXT_OPEN_RE = /^<(script|pre|style|textarea)(\s|>|$)/i;
22
27
  const componentStack = [];
23
28
  for (let idx = 0; idx < n; idx++) {
24
29
  const line = lines[idx];
25
30
  const trimmed = line.trim();
31
+ // Raw-text HTML element: skip all line-level processing inside its body,
32
+ // and update the open/close state. Open and close can sit on the same
33
+ // line (e.g. `<style>body { ... }</style>` inline).
34
+ if (inRawTextElement) {
35
+ const closeRe = new RegExp(`</${inRawTextElement}\\s*>`, 'i');
36
+ if (closeRe.test(line))
37
+ inRawTextElement = null;
38
+ continue;
39
+ }
40
+ const rawTextMatch = trimmed.match(RAW_TEXT_OPEN_RE);
41
+ if (rawTextMatch) {
42
+ const tag = rawTextMatch[1].toLowerCase();
43
+ // Stay "inside" only if the close tag isn't already on this line.
44
+ const closeRe = new RegExp(`</${tag}\\s*>`, 'i');
45
+ if (!closeRe.test(line))
46
+ inRawTextElement = tag;
47
+ continue;
48
+ }
26
49
  // Frontmatter: only starts at document line 0
27
50
  if (idx === 0 && trimmed === '---') {
28
51
  inFrontmatter = true;
@@ -190,7 +213,8 @@ function closeInlineMarkersLinear(line) {
190
213
  // Count markers by scanning
191
214
  let asteriskCount = 0;
192
215
  let underscoreCount = 0;
193
- let tildeCount = 0; // Count individual tildes
216
+ let doubleTildeCount = 0; // Count ~~ occurrences (GFM strikethrough delimiter)
217
+ let singleTildeCount = 0; // Count standalone ~ (not part of ~~)
194
218
  let backtickCount = 0;
195
219
  let dollarCount = 0; // Count $ for math
196
220
  let dollarPairCount = 0; // Count $$ pairs for block math
@@ -280,7 +304,13 @@ function closeInlineMarkersLinear(line) {
280
304
  }
281
305
  }
282
306
  else if (ch === '~') {
283
- tildeCount++;
307
+ if (i + 1 < len && line[i + 1] === '~') {
308
+ doubleTildeCount++;
309
+ i++; // Skip second tilde since we counted the pair
310
+ }
311
+ else {
312
+ singleTildeCount++;
313
+ }
284
314
  }
285
315
  else if (ch === '`') {
286
316
  backtickCount++;
@@ -450,22 +480,18 @@ function closeInlineMarkersLinear(line) {
450
480
  }
451
481
  }
452
482
  }
453
- // Check ~~ (strikethrough)
454
- if (!closingSuffix && tildeCount >= 2) {
455
- const remainder = tildeCount % 4;
456
- if (remainder === 2) {
457
- // Two tildes unclosed, close with ~~
458
- closingSuffix = '~~';
459
- if (hasTrailingSpace)
460
- shouldTrim = true;
461
- }
462
- else if (remainder > 2 && remainder < 4) {
463
- // Partial marker like ~~text~ (3 tildes), need 1 more
464
- const needed = 4 - remainder;
465
- closingSuffix = '~'.repeat(needed);
466
- if (hasTrailingSpace)
467
- shouldTrim = true;
468
- }
483
+ // Check ~~ (strikethrough) and ~ (single-tilde) separately so that paired
484
+ // singles like ~Hello~ are left alone while ~~text and ~Hello both close.
485
+ if (!closingSuffix && doubleTildeCount % 2 === 1) {
486
+ // A trailing single ~ after an open ~~ is a partial closer (~~text~)
487
+ closingSuffix = singleTildeCount === 1 ? '~' : '~~';
488
+ if (hasTrailingSpace)
489
+ shouldTrim = true;
490
+ }
491
+ else if (!closingSuffix && singleTildeCount % 2 === 1) {
492
+ closingSuffix = '~';
493
+ if (hasTrailingSpace)
494
+ shouldTrim = true;
469
495
  }
470
496
  // Check ` (code)
471
497
  if (!closingSuffix && backtickCount % 2 === 1) {
@@ -30,5 +30,9 @@ export function applyAutoUnwrap(node) {
30
30
  if (nonEmptyChildren.length > 1 || typeof nonEmptyChildren[0] === 'string' || nonEmptyChildren[0][0] !== 'p') {
31
31
  return [tag, props, ...children.map((child) => applyAutoUnwrap(child))];
32
32
  }
33
- return [tag, props, ...nonEmptyChildren[0].slice(2)];
33
+ // Lift the paragraph's attrs onto the parent so trailing `{attr}` survives the unwrap.
34
+ // Parent attrs take precedence so explicit component props aren't overridden.
35
+ const paragraphAttrs = nonEmptyChildren[0][1];
36
+ const mergedProps = paragraphAttrs && Object.keys(paragraphAttrs).length > 0 ? { ...paragraphAttrs, ...props } : props;
37
+ return [tag, mergedProps, ...nonEmptyChildren[0].slice(2)];
34
38
  }
@@ -1,11 +1,10 @@
1
- // BASED ON https://github.com/serkodev/markdown-exit/blob/fe1351070a5841426223ab4a0a5c7874ba2b1257/packages/markdown-exit/src/parser/block/rules/html_block.ts
1
+ // Standard CommonMark html_block rule — see
2
+ // https://spec.commonmark.org/0.30/#html-blocks
3
+ //
4
+ // 7 sequences in priority order, each: [opener regex, closer regex, can-terminate-paragraph]
2
5
  import block_names from "./html_blocks.js";
3
6
  import { HTML_OPEN_CLOSE_TAG_RE } from "./html_re.js";
4
- // An array of opening and corresponding closing sequences for html tags,
5
- // last argument defines whether it can terminate a paragraph or not
6
- //
7
7
  const HTML_SEQUENCES = [
8
- [new RegExp(`${HTML_OPEN_CLOSE_TAG_RE.source}\\s*$`), /^<\/[^>]+>$/, true],
9
8
  [/^<(script|pre|style|textarea)(?=(\s|>|$))/i, /<\/(script|pre|style|textarea)>/i, true],
10
9
  [/^<!--/, /-->/, true],
11
10
  [/^<\?/, /\?>/, true],
@@ -17,7 +16,6 @@ const HTML_SEQUENCES = [
17
16
  export default function html_block(state, startLine, endLine, silent) {
18
17
  let pos = state.bMarks[startLine] + state.tShift[startLine];
19
18
  let max = state.eMarks[startLine];
20
- // if it's indented more than 3 spaces, it should be a code block
21
19
  if (state.sCount[startLine] - state.blkIndent >= 4)
22
20
  return false;
23
21
  if (state.src.charCodeAt(pos) !== 0x3c /* < */)
@@ -30,18 +28,14 @@ export default function html_block(state, startLine, endLine, silent) {
30
28
  }
31
29
  if (i === HTML_SEQUENCES.length)
32
30
  return false;
33
- if (silent) {
34
- // true if this sequence can be a terminator, false otherwise
31
+ if (silent)
35
32
  return HTML_SEQUENCES[i][2];
36
- }
37
33
  let nextLine = startLine + 1;
38
- // If we are here - we detected HTML block.
39
- // Let's roll down till block end.
40
- if (i !== 0 && !HTML_SEQUENCES[i][1].test(lineText)) {
34
+ // Walk forward until the closer regex matches or we hit a blank line.
35
+ if (!HTML_SEQUENCES[i][1].test(lineText)) {
41
36
  for (; nextLine < endLine; nextLine++) {
42
- if (state.sCount[nextLine] < state.blkIndent) {
37
+ if (state.sCount[nextLine] < state.blkIndent)
43
38
  break;
44
- }
45
39
  pos = state.bMarks[nextLine] + state.tShift[nextLine];
46
40
  max = state.eMarks[nextLine];
47
41
  lineText = state.src.slice(pos, max);
@@ -53,7 +47,7 @@ export default function html_block(state, startLine, endLine, silent) {
53
47
  }
54
48
  }
55
49
  state.line = nextLine;
56
- const token = lineText.startsWith('</') ? state.push('html_block_close', '', -1) : state.push('html_block', '', 1);
50
+ const token = state.push('html_block', '', 1);
57
51
  token.map = [startLine, nextLine];
58
52
  token.content = state.getLines(startLine, nextLine, state.blkIndent, true);
59
53
  return true;
@@ -1,4 +1,5 @@
1
1
  import type { ComarkNode } from 'comark';
2
+ export declare const VOID_ELEMENTS: Set<string>;
2
3
  interface HtmlTagInfo {
3
4
  tag: string;
4
5
  attrs: Record<string, unknown>;
@@ -1,5 +1,5 @@
1
1
  import { Parser } from 'htmlparser2';
2
- const VOID_ELEMENTS = new Set([
2
+ export const VOID_ELEMENTS = new Set([
3
3
  'area',
4
4
  'base',
5
5
  'br',
@@ -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') {
@@ -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
  *
@@ -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']);
@@ -34,13 +35,15 @@ export async function li(node, state) {
34
35
  result += rendered;
35
36
  }
36
37
  result = result.trim();
38
+ const attrs = comarkAttributes(userBlockAttrs('li', node[1]));
39
+ const suffix = attrs ? ` ${attrs}` : '';
37
40
  if (!order) {
38
41
  result = escapeLeadingNumberDot(result);
39
42
  }
40
43
  if (order) {
41
44
  state.applyContext({ order: order + 1 });
42
45
  }
43
- return `${prefix}${result}\n`;
46
+ return `${prefix}${result}${suffix}\n`;
44
47
  }
45
48
  function escapeLeadingNumberDot(str) {
46
49
  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,4 +1,5 @@
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
5
  const revert = state.applyContext({ list: true, order: 1, listIndent: 3 });
@@ -7,12 +8,21 @@ export async function ol(node, state) {
7
8
  result += await state.one(child, state);
8
9
  }
9
10
  result = result.trim();
11
+ state.applyContext(revert);
12
+ // ol with user attrs round-trips via `::ol{attrs}\n1. …\n::` — the native
13
+ // markdown list syntax has no slot for list-level attrs.
14
+ const attrs = comarkAttributes(userBlockAttrs('ol', node[1]));
15
+ if (attrs) {
16
+ if (revert.list) {
17
+ return '\n' + indent(`::ol${attrs}\n${result}\n::`, { width: revert.listIndent || 2 });
18
+ }
19
+ return `::ol${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
  }
@@ -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) {