@helping-ai-workflow/md2doc 3.17.8 → 3.17.9

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.
@@ -279,6 +279,9 @@
279
279
  const uniform = widths.map(() => true);
280
280
  let sepFields = null;
281
281
  for (let r = 1; r < rows.length; r++) {
282
+ // A lazy body line (no '|' at all, v3.17.9 D7) has no fields and so no
283
+ // widths: it neither voids nor sets the table's width memory.
284
+ if (r >= 2 && rows[r].indexOf('|') === -1) continue;
282
285
  const fields = splitFields(rows[r]);
283
286
  // A ragged original is not a width source — bail rather than guess.
284
287
  if (!fields || fields.length !== widths.length) return null;
@@ -361,8 +364,29 @@
361
364
  // an untouched cell's comments, e.g. the `<!-- [[source, Cl.x]] -->`
362
365
  // citation anchors in paperwork's hwe1 tables, which the canonical
363
366
  // spelling would drop.
367
+ //
368
+ // v3.17.9 D7: a STRAY end tag (no start tag of that name earlier in the
369
+ // cell) is dropped too, for the same reason: the browser's parser ignores
370
+ // it, so the DOM never has it (`</content>` under a table). Kept, so the
371
+ // cell reads by its bytes as before: `</p>` and `</br>`, which the parser
372
+ // turns into an empty paragraph and a line break, and the table-structure
373
+ // names (some close the cell, some are ignored; keeping a tag only means
374
+ // the cell is reused when byte-equal, so the list errs on the safe side).
375
+ const STRAY_END_KEPT = /^(?:p|br|table|caption|colgroup|col|tbody|thead|tfoot|tr|td|th|template)$/;
376
+ function dropStrayEndTags(parts) {
377
+ const open = Object.create(null);
378
+ for (let i = 1; i < parts.length; i += 2) {
379
+ const m = /^<(\/?)([A-Za-z][A-Za-z0-9-]*)/.exec(parts[i]);
380
+ if (!m) continue;
381
+ const name = m[2].toLowerCase();
382
+ if (!m[1]) { open[name] = (open[name] || 0) + 1; continue; }
383
+ if (open[name]) { open[name] -= 1; continue; }
384
+ if (!STRAY_END_KEPT.test(name)) parts[i] = '';
385
+ }
386
+ return parts;
387
+ }
364
388
  function normalizeCellHtml(html) {
365
- return html.replace(/<!--[\s\S]*?-->/g, '').split(/(<[^>]*>)/).map((part, i) => (i % 2 ? part : part
389
+ return dropStrayEndTags(html.replace(/<!--[\s\S]*?-->/g, '').split(/(<[^>]*>)/)).map((part, i) => (i % 2 ? part : part
366
390
  .replace(/&(?:#([0-9]+)|#[xX]([0-9a-fA-F]+)|([A-Za-z][A-Za-z0-9]*));/g, decodeRef)
367
391
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'))).join('');
368
392
  }
@@ -410,6 +434,15 @@
410
434
  // `line` is the source line trimmed. Unlike columnWidthsOf(), a ragged row
411
435
  // does not void the table: reuse is decided per cell. null when there is
412
436
  // no original or its header line is not a row.
437
+ //
438
+ // v3.17.9 D7: a body line with no '|' at all is still a row to marked (GFM:
439
+ // any non-blank line continues the table), e.g. a stray `</content>` right
440
+ // under a table. marked's splitCells() reads it as the whole line in the
441
+ // first cell and the rest empty, and so does this (`lazy: true`). Before
442
+ // this it was never paired, and an edit anywhere in the table rewrote it
443
+ // as `| | |`: the browser drops a stray end tag, so its cells are empty
444
+ // in the DOM. A line holding a '|' but not starting with one stays
445
+ // unreadable here, as before.
413
446
  function originalRowsOf(originalMd) {
414
447
  if (typeof originalMd !== 'string' || originalMd === '') return null;
415
448
  const rows = originalMd.split('\n').filter((l) => l.trim() !== '');
@@ -417,7 +450,13 @@
417
450
  const row = (l) => { const f = splitFields(l); return f ? { fields: f, line: l.trim() } : null; };
418
451
  const header = row(rows[0]);
419
452
  if (!header) return null;
420
- return { header, body: rows.slice(2).map(row) };
453
+ const lazy = (l) => {
454
+ if (l.indexOf('|') !== -1) return null;
455
+ const fields = header.fields.map(() => '');
456
+ fields[0] = l.trim();
457
+ return { fields, line: l.trim(), lazy: true };
458
+ };
459
+ return { header, body: rows.slice(2).map((l) => row(l) || lazy(l)) };
421
460
  }
422
461
 
423
462
  // Pairs each current body row with an original body row (an index into
@@ -432,14 +471,16 @@
432
471
  // Linear in rows (final review M1: the scans were quadratic, 3000 rows
433
472
  // that all differ took 3 s): each row is hashed on its mapped cells'
434
473
  // readingKey(), so "matches" is one Map lookup.
474
+ // A lazy original (v3.17.9 D7) has no key through the column map: its
475
+ // cells are positions, not named columns, so it is matched by position
476
+ // (see `pos` below), in the file's order like every other row.
435
477
  function pairBodyRows(curRows, origBody, colMap, keyOf) {
436
478
  const mapped = [];
437
479
  colMap.forEach((k, i) => { if (k !== undefined) mapped.push(i); });
438
480
  const pair = new Array(curRows.length).fill(-1);
439
- if (mapped.length === 0) return pair;
440
481
  const SEP = '\u0000';
441
482
  const origKey = origBody.map((o) => {
442
- if (!o) return null;
483
+ if (!o || o.lazy || mapped.length === 0) return null;
443
484
  const parts = [];
444
485
  for (let x = 0; x < mapped.length; x++) {
445
486
  const f = o.fields[colMap[mapped[x]]];
@@ -459,26 +500,61 @@
459
500
  });
460
501
  const used = new Array(origBody.length).fill(false);
461
502
  const anchor = new Array(curRows.length).fill(false);
503
+ // A table with a lazy original (v3.17.9 D7) is first matched position by
504
+ // position over every cell: `pos` holds each original's positional key,
505
+ // lazy or not, and a row takes the first unused original at or after j
506
+ // whose every cell reads the same, before any match through the column
507
+ // map. Through the map a renamed header leaves few cells (or none) to
508
+ // match on, so an ordinary original whose mapped cells happen to match
509
+ // (all empty, say) could take the row a lazy line is in. Kept in the
510
+ // file's order (re-review round 3 C1): paired before everything else, a
511
+ // lazy line took the first row reading like it, and an ordinary row that
512
+ // read the same as a lazy line below it swapped lines with it on a no-op.
513
+ let pos = null;
514
+ let curPos = null;
515
+ const lazyAt = origBody.findIndex((o) => o && o.lazy);
516
+ if (lazyAt !== -1) {
517
+ const n = origBody[lazyAt].fields.length;
518
+ const posKey = (get) => Array.from({ length: n }, (_, i) => keyOf(get(i))).join(SEP);
519
+ pos = new Map();
520
+ origBody.forEach((o, q) => {
521
+ if (!o) return;
522
+ const k = posKey((i) => (o.fields[i] === undefined ? '' : o.fields[i].trim()));
523
+ let e = pos.get(k);
524
+ if (!e) { e = { list: [], p1: 0, p2: 0 }; pos.set(k, e); }
525
+ e.list.push(q);
526
+ });
527
+ curPos = curRows.map((row) => posKey((i) => (row[i] === undefined ? '' : row[i])));
528
+ }
462
529
  // (1) forward greedy: the first unused match at or after j. Every original
463
530
  // used in this step lies before j, so a cursor that skips indices < j
464
531
  // finds it, and it only moves forward.
532
+ const firstFrom = (e) => {
533
+ if (!e) return -1;
534
+ while (e.p1 < e.list.length && e.list[e.p1] < j) e.p1++;
535
+ return e.p1 < e.list.length ? e.p1 : -1;
536
+ };
465
537
  let j = 0;
466
538
  for (let c = 0; c < curRows.length; c++) {
467
- const e = byKey.get(curKey[c]);
468
- if (!e) continue;
469
- while (e.p1 < e.list.length && e.list[e.p1] < j) e.p1++;
470
- if (e.p1 < e.list.length) {
471
- const q = e.list[e.p1++];
472
- pair[c] = q; used[q] = true; anchor[c] = true; j = q + 1;
473
- }
539
+ let e = pos ? pos.get(curPos[c]) : undefined;
540
+ let at = firstFrom(e);
541
+ if (at === -1) { e = byKey.get(curKey[c]); at = firstFrom(e); }
542
+ if (at === -1) continue;
543
+ const q = e.list[at];
544
+ e.p1 = at + 1;
545
+ pair[c] = q; used[q] = true; anchor[c] = true; j = q + 1;
474
546
  }
475
547
  // (2) a leftover row that matches an unused original anywhere (moved).
548
+ const firstUnused = (e) => {
549
+ if (!e) return -1;
550
+ while (e.p2 < e.list.length && used[e.list[e.p2]]) e.p2++;
551
+ return e.p2 < e.list.length ? e.list[e.p2++] : -1;
552
+ };
476
553
  for (let c = 0; c < curRows.length; c++) {
477
554
  if (pair[c] !== -1) continue;
478
- const e = byKey.get(curKey[c]);
479
- if (!e) continue;
480
- while (e.p2 < e.list.length && used[e.list[e.p2]]) e.p2++;
481
- if (e.p2 < e.list.length) { const q = e.list[e.p2++]; pair[c] = q; used[q] = true; }
555
+ let q = pos ? firstUnused(pos.get(curPos[c])) : -1;
556
+ if (q === -1) q = firstUnused(byKey.get(curKey[c]));
557
+ if (q !== -1) { pair[c] = q; used[q] = true; }
482
558
  }
483
559
  // (3) the rows still left between two anchors take the unused originals
484
560
  // between the same anchors, in order.
@@ -787,9 +863,15 @@
787
863
  headerContents = h.contents;
788
864
  headerWhole = h.whole;
789
865
  const pair = pairBodyRows(bodyContentRows, src.body, colIdx, (x) => readingKey(parse, x));
866
+ // A lazy line's cells are positions in marked's split, not columns with
867
+ // a name, and its text is often in the file only (D7): a renamed header
868
+ // must not unmatch it. So it is mapped by position; every cell is still
869
+ // gated by readsSame(), so an edited cell is never overwritten.
870
+ const byPosition = headerContents.map((_, i) => (i < nOrigCols ? i : undefined));
790
871
  bodyContentRows = bodyContentRows.map((canon, r) => {
791
872
  const orig = pair[r] === -1 ? null : src.body[pair[r]];
792
- const b = reuseRow(parse, canon, bodyCellMd[r].map((x) => x.bad), orig, colIdx, nOrigCols);
873
+ const map = orig && orig.lazy ? byPosition : colIdx;
874
+ const b = reuseRow(parse, canon, bodyCellMd[r].map((x) => x.bad), orig, map, nOrigCols);
793
875
  bodyWhole[r] = b.whole;
794
876
  bodyOrig[r] = orig;
795
877
  return b.contents;
@@ -810,7 +892,9 @@
810
892
  // comparing a number against `undefined` is always false, so this
811
893
  // loop leaves it alone and padField()'s `|| 0` fallback below
812
894
  // gives it independent per-row minimal form instead.
813
- const allRows = [headerContents].concat(bodyContentRows);
895
+ // A lazy line that is written as itself (D7) is no column's content.
896
+ const allRows = [headerContents].concat(bodyContentRows.filter((_, r) =>
897
+ !(bodyWhole[r] && bodyOrig[r] && bodyOrig[r].lazy)));
814
898
  for (let i = 0; i < widths.length; i++) {
815
899
  let needed = widths[i];
816
900
  allRows.forEach((row) => {
@@ -825,9 +909,10 @@
825
909
 
826
910
  // F1: a row whose cells were all reused, in the original columns, is the
827
911
  // original line itself (trimmed), padding and all, unless a column it
828
- // holds was widened past that line's own field.
829
- const asWritten = (whole, orig) => whole && orig.fields.every((f, i) =>
830
- !widths || widths[i] === undefined || widths[i] <= f.length);
912
+ // holds was widened past that line's own field. A lazy line (D7) has no
913
+ // fields to pad, so it is always its own line.
914
+ const asWritten = (whole, orig) => whole && (orig.lazy || orig.fields.every((f, i) =>
915
+ !widths || widths[i] === undefined || widths[i] <= f.length));
831
916
  const lines = [];
832
917
  lines.push(asWritten(headerWhole, src && src.header) ? src.header.line
833
918
  : serializeRow(headerContents, widths));
package/lib/md2doc.js CHANGED
@@ -233,6 +233,117 @@ try {
233
233
  ],
234
234
  });
235
235
 
236
+ // v3.17.9 D8: `[[ref-id, §sub]]` → a clickable citation, as an inline
237
+ // extension for the same reason as sub / sup above. It was a raw-text
238
+ // pre-pass over the whole file (mdPre), which rewrote it inside code spans,
239
+ // fenced code, HTML comments and tag attributes too: the code then showed
240
+ // the anchor's HTML as text, and the editor wrote that HTML back into the
241
+ // file on the first edit of the block. `text` is the anchor's visible text,
242
+ // escaped as the old html + text tokens were, so heading ids and the
243
+ // search index read the same strings as before. The body is literal: no
244
+ // bold / code / escapes inside a citation any more (the pre-pass left them
245
+ // to marked), which is what the editor writes back (isCitationEligible).
246
+ const escCiteAttr = (s) => String(s)
247
+ .replace(/&/g, '&amp;')
248
+ .replace(/</g, '&lt;')
249
+ .replace(/>/g, '&gt;')
250
+ .replace(/"/g, '&quot;');
251
+ marked.use({
252
+ extensions: [{
253
+ name: 'citation',
254
+ level: 'inline',
255
+ // No start(): marked's inline text rule already stops before every
256
+ // `[`, and this tokenizer is tried there. A start() that searched the
257
+ // rest of the paragraph for `[[` ran once per text token (re-review
258
+ // round 3 C3: quadratic, a 32000-character paragraph of `[a] ` took 0.2 s).
259
+ tokenizer(src) {
260
+ const m = /^\[\[([^\]\n]+)\]\]/.exec(src);
261
+ if (!m) return undefined;
262
+ const body = m[1].trim();
263
+ return { type: 'citation', raw: m[0], body, slug: body.split(',', 1)[0].trim(),
264
+ text: '[' + escCiteAttr(body) + ']' };
265
+ },
266
+ renderer(token) { return `<a href="#${escCiteAttr(token.slug)}">${token.text}</a>`; },
267
+ }],
268
+ });
269
+ // A citation inside a link label or an image alt: marked's label allows one
270
+ // nested bracket level and `[[...]]` is two, so `[see [[ref]]](#sec)` fell
271
+ // apart into text (the old pre-pass had removed one level first). Each
272
+ // citation in the label is masked as `\u0000[body]\u0000` (same length, one
273
+ // level), marked's own link tokenizer reads the masked text, and the link's
274
+ // raw / label come back from the real source: a link's label is re-lexed, so
275
+ // its citations render; an image's alt is the citations' visible text.
276
+ // A `[[` after an odd backslash run is escaped text, not a citation (the
277
+ // inline lexer reads `\[` as a literal bracket), so it is left unmasked.
278
+ // The mask is the regex /(\\*)\[\[([^\]\n]+)\]\]/g scanned by hand, in
279
+ // one pass (re-review round 3 C3): the tokenizer runs at every `[` of a
280
+ // paragraph, and the regex let each unclosed `[[` scan to the end of its
281
+ // line, so one 8000-character line of `[[a ` took 10 s. `stop` is the
282
+ // first `]` or newline at or after a body's start; it only moves forward.
283
+ const maskCitations = (s) => {
284
+ let out = '';
285
+ let from = 0;
286
+ let stop = -1;
287
+ for (let p = s.indexOf('[['); p !== -1; p = s.indexOf('[[', p)) {
288
+ if (stop < p + 2) {
289
+ stop = p + 2;
290
+ while (stop < s.length && s[stop] !== ']' && s[stop] !== '\n') stop++;
291
+ }
292
+ if (stop === p + 2 || s[stop] !== ']' || s[stop + 1] !== ']') { p++; continue; }
293
+ let bs = 0;
294
+ while (p - bs - 1 >= from && s[p - bs - 1] === '\\') bs++;
295
+ out += bs % 2 ? s.slice(from, stop + 2) : s.slice(from, p) + '\u0000[' + s.slice(p + 2, stop) + ']\u0000';
296
+ from = p = stop + 2;
297
+ }
298
+ return from ? out + s.slice(from) : s;
299
+ };
300
+ // Where the label of a link (or alt of an image) starting at `src` ends:
301
+ // the index of its closing `]`, or -1. marked's label allows escapes, code
302
+ // spans (at its own level only: inside a nested bracket a backtick is a
303
+ // plain character, re-review round 4) and one nested bracket level, and a
304
+ // masked citation is one level,
305
+ // so deeper than three raw levels is no label of marked's. Bounded so that
306
+ // a call costs about the distance to the next brackets: the tokenizer runs
307
+ // at the start of every token of a paragraph (re-review round 3 C3).
308
+ const labelEnd = (src) => {
309
+ const start = src[0] === '!' ? 1 : 0;
310
+ if (src[start] !== '[') return -1;
311
+ let depth = 0;
312
+ for (let i = start; i < src.length; i++) {
313
+ const ch = src[i];
314
+ if (ch === '\\') i++;
315
+ else if (ch === '`' && depth === 1) { const k = src.indexOf('`', i + 1); if (k !== -1) i = k; }
316
+ else if (ch === '[') { if (++depth > 3) return -1; }
317
+ else if (ch === ']') { if (--depth === 0) return i; }
318
+ }
319
+ return -1;
320
+ };
321
+ const defaultLink = marked.Tokenizer.prototype.link;
322
+ marked.use({ tokenizer: { link(src) {
323
+ // only an inline link or image whose label holds a `[[` is read here; a
324
+ // citation in its href or title stays literal (unmasked), as it is in a
325
+ // link whose label cites nothing (re-review round 3 C4)
326
+ const e = labelEnd(src);
327
+ if (e === -1 || src[e + 1] !== '(' || src.lastIndexOf('[[', e) === -1) return false;
328
+ const masked = maskCitations(src.slice(0, e + 1)) + src.slice(e + 1);
329
+ const tok = defaultLink.call(this, masked);
330
+ if (!tok || tok.raw.indexOf('\u0000') === -1) return false;
331
+ // a mask byte past the label means the scan disagreed with marked's
332
+ // label; leave the link to marked rather than leak the byte
333
+ if ((tok.href && tok.href.indexOf('\u0000') !== -1) || (tok.title && tok.title.indexOf('\u0000') !== -1)) return false;
334
+ tok.raw = src.slice(0, tok.raw.length);
335
+ if (tok.type === 'image') { tok.text = tok.text.replace(/\u0000/g, ''); return tok; }
336
+ // The label is lexed with its escapes in place (marked unescapes `\[`
337
+ // first, which would turn an escaped `\[[x]]` back into a citation).
338
+ const cap = this.rules.inline.link.exec(masked);
339
+ const label = src.substr(1, cap[1].length);
340
+ this.lexer.state.inLink = true;
341
+ tok.text = label.replace(/\\([\[\]])/g, '$1');
342
+ tok.tokens = this.lexer.inlineTokens(label);
343
+ this.lexer.state.inLink = false;
344
+ return tok;
345
+ } } });
346
+
236
347
  // $$…$$ (display) and $…$ (inline) math via KaTeX. marked tokenizes code
237
348
  // spans/fences first, so $ inside code stays literal; the extension's
238
349
  // default no-space-adjacency rules keep prose currency ($5 to $10) unrendered.
@@ -887,6 +998,14 @@ ${itemsHtml}
887
998
  if (item.type === 'codespan') {
888
999
  return unescapeHtml(item.text || '');
889
1000
  }
1001
+ // v3.17.9 D8: what the old [[...]] text pre-pass left here, an html
1002
+ // token's markup plus the text, so classifyColumns() (it measures the
1003
+ // markup: a citation column often reads as prose) and heading ids
1004
+ // (stripHtmlTags() removes it) see the strings they always did. A
1005
+ // patch release does not re-lay-out every table that cites.
1006
+ if (item.type === 'citation') {
1007
+ return '<a href="#' + item.slug + '">[' + item.body + ']</a>';
1008
+ }
890
1009
  if (item.tokens) {
891
1010
  return flattenTokenText(item.tokens);
892
1011
  }
@@ -1361,35 +1480,26 @@ ${itemsHtml}
1361
1480
  // concurrently, unlike the one-shot CLI).
1362
1481
  marked.setOptions({ gfm: true, breaks: false, renderer });
1363
1482
 
1364
- // Pre-process the remaining non-standard inline syntax before marked parses
1365
- const escAttr = (s) => String(s)
1366
- .replace(/&/g, '&amp;')
1367
- .replace(/</g, '&lt;')
1368
- .replace(/>/g, '&gt;')
1369
- .replace(/"/g, '&quot;');
1370
- const mdPre = md
1371
- .replace(/\[\[([^\]\n]+)\]\]/g, (_, inner) => { // [[ref-id, §sub]] → clickable citation
1372
- const body = inner.trim();
1373
- const slug = body.split(',', 1)[0].trim();
1374
- return `<a href="#${escAttr(slug)}">[${escAttr(body)}]</a>`;
1375
- });
1483
+ // `[[ref-id, §sub]]` citations are the `citation` inline extension
1484
+ // installed at require time (v3.17.9 D8); there is no text pre-pass, so
1485
+ // marked reads the file's own bytes and the block map's lines are the
1486
+ // file's lines.
1376
1487
 
1377
1488
  // A $$ block's TeX reaches the index through the blockKatex hook at the
1378
1489
  // top of the file, set only while this render's synchronous parse runs.
1379
1490
  if (opts.editMode) {
1380
- // Ranges are computed on mdPre. The [[...]] preprocessing above only
1381
- // replaces text within single lines and never adds/removes newlines,
1382
- // so line ranges stay valid against the ORIGINAL md the client holds
1383
- // (guarded by the '[[...]] must not shift lines' test).
1491
+ // Ranges are computed on the file's own text (no pre-pass since
1492
+ // v3.17.9), so they are the lines the client holds (still guarded by
1493
+ // the '[[...]] must not shift lines' test).
1384
1494
  const { buildBlockMap } = require('./editor/blockmap.js');
1385
- blocks = buildBlockMap(mdPre).blocks;
1495
+ blocks = buildBlockMap(md).blocks;
1386
1496
  // marked.lexer() inline-lexes as part of block lexing (verified against
1387
1497
  // marked 14.1.4: a paragraph token's `.tokens` already holds resolved
1388
1498
  // inline tokens such as `strong`/`link`, not raw text) — so
1389
1499
  // marked.parser([t]) on one already-lexed top-level token reproduces
1390
1500
  // exactly what the whole-document parse would emit for that token,
1391
1501
  // using the same renderer/options set above.
1392
- const tokens = marked.lexer(mdPre);
1502
+ const tokens = marked.lexer(md);
1393
1503
  parts = [];
1394
1504
  // `v` is the block cursor; `prevLi`/`types` are the §3.8 run state the
1395
1505
  // flat list renderer above threads through its own recursion (see
@@ -1465,7 +1575,7 @@ ${itemsHtml}
1465
1575
  } else {
1466
1576
  try {
1467
1577
  blockMathText = appendSectionText;
1468
- bodyHtml = marked.parse(mdPre);
1578
+ bodyHtml = marked.parse(md);
1469
1579
  } finally {
1470
1580
  blockMathText = null;
1471
1581
  }
@@ -6790,7 +6900,7 @@ ${usesDrawioMultiPage ? ` // v3.4.0 batch2 Task 6: wired through a NAMED, reusa
6790
6900
  // v3.4.0 §4: same short-circuit story for drawio. editMode already baked
6791
6901
  // every part above, so both calls below are no-ops there (no browser is
6792
6902
  // opened) — this is only load-bearing for the non-editMode
6793
- // `bodyHtml = marked.parse(mdPre)` path, which never went through the
6903
+ // `bodyHtml = marked.parse(md)` path, which never went through the
6794
6904
  // per-part loop. `html` and `bodyHtml` share the same placeholders (html
6795
6905
  // embeds bodyHtml), so both bakes share one lazily-opened browser instead
6796
6906
  // of paying for two.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.17.8",
3
+ "version": "3.17.9",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",