@helping-ai-workflow/md2doc 3.10.0 → 3.11.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.
@@ -91,19 +91,24 @@ function stripMarker(sourceLines, blockKind) {
91
91
  if (blockKind === 'li') {
92
92
  const m = src[0].match(LI_MARKER);
93
93
  if (!m) return { content: [], ok: false };
94
- // A MULTI-LINE li refuses. The previous code flattened continuations with
95
- // replace(/^\s+/, ''), which is exactly the guess the code branch below
96
- // refuses to make for an indented code block -- and it silently destroys
97
- // whatever structure the continuation carried (a nested fence, an indented
98
- // sub-block). Today the \u00a74.1 gate refuses a multi-line li as an operation
99
- // target before this is ever reached, so this is defence in depth; dead
100
- // code in this repo has a history of later gaining a caller, and it must
101
- // not be a mine when it does.
102
- if (src.length > 1) return { content: [], ok: false };
103
94
  let first = src[0].slice(m[0].length);
104
95
  const box = first.match(TASK_BOX);
105
96
  if (box) first = first.slice(box[0].length);
106
- return { content: [first], ok: true };
97
+ // v3.11: a MULTI-LINE li (a hard-wrapped item, or a hard break) converts
98
+ // too. Its continuation lines belong to the item's own paragraph — any
99
+ // nested block would have made the run unsupported and refused before
100
+ // this — so each loses exactly the item's CONTENT column (the marker's
101
+ // width: anything deeper keeps its relative indent) or, on a lazy line
102
+ // indented less than that, all of its leading whitespace. That is not the
103
+ // old blanket replace(/^\s+/, '') the code branch below refuses to guess
104
+ // with: an over-indented line keeps its extra columns.
105
+ const col = m[0].length;
106
+ const rest = src.slice(1).map((l) => {
107
+ let k = 0;
108
+ while (k < col && l.charAt(k) === ' ') k++;
109
+ return k === col ? l.slice(col) : l.replace(/^\s+/, '');
110
+ });
111
+ return { content: [first].concat(rest), ok: true };
107
112
  }
108
113
 
109
114
  if (blockKind === 'heading') {
@@ -160,7 +165,9 @@ function emitAs(content, target, opts) {
160
165
  // A heading is one line by definition. Joining is the only lossless move
161
166
  // available; splitting would make one gesture produce two blocks.
162
167
  const hashes = '#'.repeat(Number(target.slice(1)));
163
- const text = body.join(' ');
168
+ // A multi-line body (a converted hard-wrapped item) becomes one heading
169
+ // line; trailing hard-break spaces would otherwise land mid-heading.
170
+ const text = body.length > 1 ? body.map((l) => l.trim()).join(' ') : body.join(' ');
164
171
  // An EMPTY heading emits no trailing space, or converting '##' to a
165
172
  // heading of the same level would rewrite the line to '## '.
166
173
  return [text === '' ? prefix + hashes : prefix + hashes + ' ' + text];
@@ -106,6 +106,14 @@
106
106
 
107
107
  function serializeAnchor(node, unsupported) {
108
108
  const href = node.getAttribute('href');
109
+ // A raw HTML anchor (`<a id="ref-x"></a>`, NO href attribute) is not a
110
+ // markdown link: emitting '[label]()' for it erased the anchor on the
111
+ // first edit of its block. Unsupported, so the block keeps its source.
112
+ // An empty href ('' — from '[x]()') is still a link.
113
+ if (href === null) {
114
+ unsupported.push('A');
115
+ return '';
116
+ }
109
117
  const text = node.textContent;
110
118
  // citation: <a href="#slug">[body]</a> -> [[body]]
111
119
  if (href && href.charAt(0) === '#' && /^\[.*\]$/.test(text)) {
@@ -450,6 +450,15 @@
450
450
  // replace from rewriting lines the user never looked at.
451
451
  function serializeBlocks(blockEls, opts) {
452
452
  const carryOver = (opts && opts.carryOver) || null;
453
+ // v3.11 (batch-2 §6): `opts.blankBefore(blockEl, prevItemEl)` — true when a
454
+ // blank line separates this item from the item emitted before it. The
455
+ // CALLER decides (client.js reads it off the file: a loose list keeps the
456
+ // blank lines it had, and a new neighbour takes the spacing of the list it
457
+ // joins). The blank line gets a lineMeta entry with blockId null and
458
+ // `blank: true`, so
459
+ // `lineMeta.length === md.split('\n').length` still holds.
460
+ const blankBefore = (opts && typeof opts.blankBefore === 'function') ? opts.blankBefore : null;
461
+ let prevItemEl = null;
453
462
  const unsupported = [];
454
463
  const unsupportedByLi = [];
455
464
  const multiLineBlockIds = [];
@@ -586,8 +595,16 @@
586
595
  // keep. (isBlankBlockText() stays as-is for the block's own direct
587
596
  // children, where the template's inter-element whitespace can legally
588
597
  // be a single space with no newline.)
589
- const inlineKids = [];
598
+ let inlineKids = [];
590
599
  allChildNodes(textEl).forEach((c) => { if (!isBlankText(c)) inlineKids.push(c); });
600
+ // v3.11 (batch-2 §6): a LOOSE item's surface is one <p> — its content
601
+ // is that paragraph's, and the blank lines that made the list loose
602
+ // are restated between items (opts.blankBefore below), not carried by
603
+ // the <p>. Two or more <p> (a multi-paragraph item) still reach the
604
+ // inline serializer whole and stay unsupported.
605
+ if (inlineKids.length === 1 && inlineKids[0].nodeType === 1 && inlineKids[0].nodeName === 'P') {
606
+ inlineKids = allChildNodes(inlineKids[0]);
607
+ }
591
608
  const res = inlineMd.serializeInline({ childNodes: inlineKids });
592
609
  itemMd = res.md;
593
610
  res.unsupported.forEach((u) => innerUnsupported.push(u));
@@ -709,6 +726,14 @@
709
726
  // Round 6: join an open task prefix (above) to this line, dropping the
710
727
  // columns it already wrote; then, if THIS item is itself a content-free
711
728
  // task item, become the prefix instead of emitting.
729
+ // A line joined onto an open task prefix is the same physical line, so
730
+ // it never takes a blank in front of it.
731
+ const blankHere = !pending && lines.length > 0 && !!blankBefore && !!blankBefore(blockEl, prevItemEl);
732
+ prevItemEl = blockEl;
733
+ if (blankHere) {
734
+ lines.push('');
735
+ lineMeta.push({ blockId: null, indentPrefix: '', marker: '', blank: true });
736
+ }
712
737
  let head = indentPrefix + marker;
713
738
  // `indentPrefix` in lineMeta is the text PHYSICALLY standing before this
714
739
  // line's own marker. Normally that is the accumulated width; on a line a
package/lib/md2doc.js CHANGED
@@ -360,10 +360,14 @@ function renderEditModeList(listToken, blocks, biRef, out) {
360
360
  const runStart = liRunStartsHere(biRef, b) ? ' data-run-start="1"' : '';
361
361
  const listStartAttr = listStart ? ' data-list-start="1"' : '';
362
362
  listStart = false;
363
+ // v3.11 (batch-2 §6): marked's own looseness for this list token, read
364
+ // by the lexer off the source (a blank line between items). The editor
365
+ // gives a NEW neighbour in this list the same spacing.
366
+ const looseAttr = listToken.loose ? ' data-list-loose="1"' : '';
363
367
  parts.push(
364
368
  `<div class="ed-block" data-block-id="${b.id}" data-block-type="li"` +
365
369
  ` data-list-type="${b.listType}" data-task="${b.task ? 1 : 0}" data-indent="${b.indent}"` +
366
- runStart + listStartAttr + ` style="--ed-indent:${b.indent}">` +
370
+ runStart + listStartAttr + looseAttr + ` style="--ed-indent:${b.indent}">` +
367
371
  `<span class="ed-li-marker" aria-hidden="true"></span>` + check +
368
372
  `<div class="ed-li-text">${inner}</div>` +
369
373
  `</div>`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.10.0",
3
+ "version": "3.11.0",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",