@helping-ai-workflow/md2doc 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -145,7 +145,63 @@
145
145
  // in the opposite direction (a zero-width "after" range) to let the
146
146
  // existing UndoStack undo()/redo() pair (lib/editor/lineops.js) handle a
147
147
  // pure insertion/pure removal without a third op shape.
148
- function commitBlockInsertion(state, blockId, newBlockLines) {
148
+ /**
149
+ * Is every line of this paste part of a list?
150
+ *
151
+ * "Part of a list" is a list line OR an indented continuation of one — a
152
+ * wrapped sentence, a nested block, a fenced snippet inside an item. Blank
153
+ * lines inside the paste are allowed (a list may be loose on purpose; what
154
+ * this decides is only whether to ADD one at the seam).
155
+ *
156
+ * A paste that is a list FOLLOWED BY a paragraph answers false, and should:
157
+ * the blank line before that paragraph is what keeps it out of the last
158
+ * item, and the seam's own blank is the lesser problem of the two.
159
+ */
160
+ function pasteIsAllListLines(pastedLines) {
161
+ let sawListLine = false;
162
+ for (const raw of pastedLines) {
163
+ const line = String(raw == null ? '' : raw);
164
+ if (line.trim() === '') continue;
165
+ if (LIST_LINE_RE.test(line)) { sawListLine = true; continue; }
166
+ // A continuation has to be indented AND it has to belong to something.
167
+ if (sawListLine && /^[ \t]/.test(line)) continue;
168
+ return false;
169
+ }
170
+ return sawListLine;
171
+ }
172
+
173
+ // Does this line open a list item? Deliberately the same shape as
174
+ // list-md.js's own SRC_MARKER_RE (`^([ \t]*)(?:[-*+]|\d{1,9}[.)])([ \t]+)…`),
175
+ // which is function-local there and answers a different question — it
176
+ // PARSES a marker, this only CLASSIFIES a line. The two are kept in
177
+ // agreement by a test that runs both over the same table of lines rather
178
+ // than by sharing one object: making that one exportable would drag a
179
+ // browser-side module into commitBlockInsertion(), whose whole value is
180
+ // that it is a pure function node can call directly.
181
+ const LIST_LINE_RE = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+/;
182
+
183
+ /**
184
+ * `opts.tight` — insert with NO leading blank line, and skip the trailing
185
+ * one when what follows is itself a list line.
186
+ *
187
+ * The always-on blank is right for a paragraph and wrong for a list item:
188
+ * a blank line between items makes the list LOOSE, every item then renders
189
+ * as <p>, serializeBlocks() answers 'P' per item, and the run degrades
190
+ * READ-ONLY with no banner. A user meets that as "I pasted an item beside
191
+ * my list and now Tab does nothing" — no error, no explanation, nothing to
192
+ * search for.
193
+ *
194
+ * It is why insertListItemAfter() and duplicateListItems() bypass this
195
+ * function. Paste could not: it carries arbitrary pasted lines, and
196
+ * re-serialising a run to absorb them would REWRITE the user's bytes —
197
+ * marker widths, ordinals, and the escaping trap duplicateListItems()
198
+ * records ('~5px' coming back as '\~5px'). So paste keeps the raw-line
199
+ * insert and turns the leading blank off instead.
200
+ *
201
+ * Opt-in, decided by the caller: every other call site wants the blank.
202
+ */
203
+ function commitBlockInsertion(state, blockId, newBlockLines, opts) {
204
+ const tight = !!(opts && opts.tight);
149
205
  const block = state.blocks.find((b) => b.id === blockId);
150
206
  // T8 item 1, third helper in the same family: the anchor's range is read as
151
207
  // an interval here too (`endLine + 1` is the insertion point, `lines[endLine]`
@@ -157,20 +213,26 @@
157
213
  if (anchorRefusal) return anchorRefusal;
158
214
  const endLine = block.endLine;
159
215
  const nextLine = state.lines[endLine]; // 0-indexed: line right after the block, or undefined at EOF
160
- const needsTrailingBlank = nextLine !== undefined && nextLine.trim() !== '';
216
+ // A list line following a tight insert needs no separator: emitting one
217
+ // would divorce THAT item from the run instead — the same defect, one
218
+ // item further down.
219
+ const needsTrailingBlank = nextLine !== undefined && nextLine.trim() !== '' &&
220
+ !(tight && LIST_LINE_RE.test(nextLine));
221
+ const lead = tight ? [] : [''];
161
222
  const after = needsTrailingBlank
162
- ? ['', ...newBlockLines, '']
163
- : ['', ...newBlockLines];
223
+ ? [...lead, ...newBlockLines, '']
224
+ : [...lead, ...newBlockLines];
164
225
  const op = { startLine: endLine + 1, endLine, before: [], after };
165
226
  const r = ops.insertLines(state.lines, endLine, after);
166
227
  const blocks = ops.shiftBlocks(state.blocks, blockId, r.delta);
167
228
  state.stack.push(op);
168
229
  // The new block's own content starts one line after the leading blank
169
- // this function always inserts (see `after` above — its first element
170
- // is always the fresh leading blank) — callers use this to locate the
171
- // freshly-inserted block in the blocks array a subsequent full
172
- // rerenderAll() (which recomputes blocks server-side) hands back.
173
- const newStartLine = op.startLine + 1;
230
+ // this function inserts by default (see `after` above) — callers use this
231
+ // to locate the freshly-inserted block in the blocks array a subsequent
232
+ // full rerenderAll() (which recomputes blocks server-side) hands back.
233
+ // A tight insert emits no leading blank, so its content begins on the
234
+ // first inserted line itself.
235
+ const newStartLine = tight ? op.startLine : op.startLine + 1;
174
236
  return { lines: r.lines, blocks, op, newStartLine };
175
237
  }
176
238
 
@@ -646,7 +708,7 @@
646
708
  }
647
709
 
648
710
  if (typeof module === 'object' && module.exports) {
649
- module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe };
711
+ module.exports = { pasteIsAllListLines, LIST_LINE_RE, extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe };
650
712
  return; // node: pure core only
651
713
  }
652
714
 
@@ -4984,7 +5046,14 @@
4984
5046
  const liveBlockId = Number(anchorEl.getAttribute('data-block-id'));
4985
5047
  const block = blocks.find((b) => b.id === liveBlockId);
4986
5048
  if (!block) return;
4987
- const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines);
5049
+ // `tight` is only ever honoured when the anchor really is a list item:
5050
+ // the caller decides the CONTENT is list-shaped, this decides the
5051
+ // DESTINATION is a list. Both halves are needed — tight-inserting a list
5052
+ // under a paragraph would glue it to that paragraph's last line.
5053
+ const tight = !!(opts && opts.tight) &&
5054
+ anchorEl.getAttribute('data-block-type') === 'li';
5055
+ const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines,
5056
+ { tight });
4988
5057
  const prevLines = lines;
4989
5058
  lines = result.lines;
4990
5059
  const editRange = result.op
@@ -14242,7 +14311,32 @@
14242
14311
  // intent has to be captured on the keystroke that produced it.
14243
14312
  let plainPasteArmed = false;
14244
14313
 
14245
- const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE = '只接受 PNG / JPEG / GIF / WebP 圖片';
14314
+ const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE =
14315
+ '只接受 PNG / JPEG / GIF / WebP 圖片,或 draw.io 的 .drawio / .xml';
14316
+ // The one place the two accepted diagram extensions are written down on
14317
+ // this side. /api/asset owns the same pair plus the content check.
14318
+ const DRAWIO_NAME_RE = /\.(drawio|xml)$/i;
14319
+
14320
+ /**
14321
+ * Is this File something the insert path will even try to send?
14322
+ *
14323
+ * ONE predicate with three call sites -- the `drop` filter,
14324
+ * `clipboardItemsOf()` and `uploadImageBlob()` -- because three copies of
14325
+ * "is this insertable" is three chances to disagree. v3.4.1's first cut
14326
+ * widened only two of them and the `drop` filter kept dropping .drawio
14327
+ * files on the floor: the filter runs BEFORE insertImages(), so the file
14328
+ * never entered the pipeline and there was no banner, no request, no
14329
+ * failure -- nothing at all happened, which is indistinguishable from the
14330
+ * feature not existing.
14331
+ *
14332
+ * "Will try to send" is the whole claim. Whether anything is WRITTEN is
14333
+ * decided by /api/asset, which re-checks the extension against its own two
14334
+ * literals and then parses the bytes.
14335
+ */
14336
+ function insertableFile(f) {
14337
+ if (!f || !assetLib) return false;
14338
+ return !!(assetLib.extFor(f.type) || DRAWIO_NAME_RE.test(f.name || ''));
14339
+ }
14246
14340
  const TOOLBAR_NO_ANCHOR_MESSAGE = '找不到可以插入圖片的位置';
14247
14341
  // backlog #10 — see the keydown handler's own comment for why every
14248
14342
  // stray-key hand-back raises this rather than only some of them.
@@ -15208,7 +15302,13 @@
15208
15302
  // authoritative.
15209
15303
  async function uploadImageBlob(blob) {
15210
15304
  if (!blob) return null;
15211
- if (!assetLib || !assetLib.extFor(blob.type)) {
15305
+ // A .drawio arrives with `type === ''` -- browsers have no media type for
15306
+ // it -- so `extFor()` alone answered "unsupported" without ever asking the
15307
+ // server. The name is enough to get the request SENT; it is not enough to
15308
+ // get anything written: /api/asset re-checks the extension against its own
15309
+ // two literals and then parses the bytes. Keep the two rules the same
15310
+ // shape, and let the server's refusal be the one the user sees.
15311
+ if (!insertableFile(blob)) {
15212
15312
  showBanner(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE, null, null);
15213
15313
  return null;
15214
15314
  }
@@ -15238,6 +15338,15 @@
15238
15338
  }
15239
15339
  let j;
15240
15340
  try { j = await res.json(); } catch (e) { j = null; }
15341
+ // A refusal carries a reason; saying "伺服器回應格式不正確" instead is a
15342
+ // second wrong answer on top of the first. The content gate added in
15343
+ // v3.4.1 makes this reachable for an ordinary mistake -- picking an .xml
15344
+ // that is not a draw.io diagram -- so the reason has to arrive.
15345
+ if (!res.ok) {
15346
+ const why = (j && typeof j.error === 'string' && j.error) ? j.error : ('HTTP ' + res.status);
15347
+ showBanner('檔案上傳失敗 — ' + why, null, null);
15348
+ return null;
15349
+ }
15241
15350
  if (!j || typeof j.path !== 'string') {
15242
15351
  showBanner('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
15243
15352
  return null;
@@ -15351,7 +15460,13 @@
15351
15460
  const dialogIdentity = captureBlockIdentity(anchorEl);
15352
15461
  const input = document.createElement('input');
15353
15462
  input.type = 'file';
15354
- input.accept = 'image/png,image/jpeg,image/gif,image/webp';
15463
+ // v3.4.1: extension tokens sit beside the MIME tokens deliberately.
15464
+ // `.drawio` has no registered media type, so a MIME-only accept list
15465
+ // cannot name it and the dialog greys the file out -- which is how
15466
+ // v3.4.0 shipped a renderer that embeds .drawio/.xml next to an insert
15467
+ // button that could not pick one. Mixing the two token kinds is what
15468
+ // the attribute is specified for.
15469
+ input.accept = 'image/png,image/jpeg,image/gif,image/webp,.drawio,.xml';
15355
15470
  input.multiple = true;
15356
15471
  input.hidden = true;
15357
15472
  // (a) above.
@@ -15436,7 +15551,7 @@
15436
15551
  const files = e.dataTransfer
15437
15552
  ? Array.prototype.slice.call(e.dataTransfer.files || [])
15438
15553
  : [];
15439
- const images = files.filter((f) => !!(assetLib && assetLib.extFor(f.type)));
15554
+ const images = files.filter(insertableFile);
15440
15555
  if (!images.length) return; // not ours: let the page do whatever it would
15441
15556
  e.preventDefault();
15442
15557
  const anchorEl = (e.target && e.target.closest) ? e.target.closest('.ed-block') : null;
@@ -15456,8 +15571,15 @@
15456
15571
  try { items['text/html'] = dt.getData('text/html') || ''; } catch (err) { items['text/html'] = ''; }
15457
15572
  const files = dt.files ? Array.prototype.slice.call(dt.files) : [];
15458
15573
  for (let i = 0; i < files.length; i += 1) {
15459
- if (files[i] && String(files[i].type).indexOf('image/') === 0) {
15460
- items[files[i].type] = files[i];
15574
+ // This map is keyed by MIME, and a .drawio has no MIME at all, so a
15575
+ // diagram pasted as a FILE goes under the fixed 'file' key that
15576
+ // pickPayload() also looks at. The key is a protocol constant between
15577
+ // these two modules -- the RULE for which files are insertable stays
15578
+ // in insertableFile() alone, so paste-md.js never grows a second
15579
+ // opinion about extensions.
15580
+ if (files[i] && insertableFile(files[i])) {
15581
+ const mime = String(files[i].type || '');
15582
+ items[mime.indexOf('image/') === 0 ? mime : 'file'] = files[i];
15461
15583
  break;
15462
15584
  }
15463
15585
  }
@@ -15524,7 +15646,9 @@
15524
15646
  // commitBlockInsertion()/replaceLines() and therefore through
15525
15647
  // serializeBlocks()'s existing gates, instead of arriving as a pile of
15526
15648
  // foreign nodes the serializer would have to make sense of afterwards.
15527
- insertBlockBelow(editEl.closest('.ed-block'), 'paragraph', { lines: md.split('\n') });
15649
+ const pastedLines = md.split('\n');
15650
+ insertBlockBelow(editEl.closest('.ed-block'), 'paragraph',
15651
+ { lines: pastedLines, tight: pasteIsAllListLines(pastedLines) });
15528
15652
  return;
15529
15653
  }
15530
15654
  snapBurstIfActive(editEl, 'paste-pre');
@@ -243,10 +243,14 @@ function htmlToMarkdown(html) {
243
243
  function pickPayload(clipboardItems, plainOnly) {
244
244
  var items = clipboardItems || {};
245
245
 
246
+ // 'file' sits beside the image/* keys: a .drawio carries no MIME, so the
247
+ // client keys it there rather than inventing one. The payload kind stays
248
+ // 'image' because its only consumer is the upload path, which asks nothing
249
+ // about the bytes -- /api/asset decides what may actually be written.
246
250
  var imageKey = null;
247
251
  for (var k in items) {
248
252
  if (Object.prototype.hasOwnProperty.call(items, k) &&
249
- k.indexOf('image/') === 0 && items[k] != null) {
253
+ (k.indexOf('image/') === 0 || k === 'file') && items[k] != null) {
250
254
  imageKey = k;
251
255
  break;
252
256
  }
@@ -670,12 +670,46 @@ async function createEditorServer({
670
670
  return send(res, 400, { error: 'fileId must be an index into the open file list' });
671
671
  }
672
672
  const file = absFiles[fileId];
673
- const ext = assetLib.extFor(body.mime);
674
- if (!ext) return send(res, 400, { error: 'unsupported image type' });
675
673
  const b64 = typeof body.data === 'string' ? body.data : '';
676
674
  const buf = Buffer.from(b64, 'base64');
677
675
  if (buf.length === 0) return send(res, 400, { error: 'empty image payload' });
678
676
 
677
+ // v3.4.1: two gates, not one widened whitelist.
678
+ //
679
+ // The raster gate is `extFor(body.mime)` and it is unchanged --
680
+ // `image/svg+xml` stays out for the reason asset.js documents (an SVG
681
+ // can carry <script>, and a MIME string is attacker-controlled).
682
+ //
683
+ // The drawio gate below exists because that first gate CANNOT answer
684
+ // for .drawio at all: browsers report `file.type === ''` for it, so
685
+ // v3.4.0 shipped a renderer that embeds `.drawio`/`.xml` and an insert
686
+ // path that answered every such upload with 'unsupported image type'.
687
+ //
688
+ // Its rule is the half that makes it safe: the EXTENSION picks the
689
+ // candidate and the CONTENT decides whether anything is written. The
690
+ // extension is matched against two literals and the stored value is
691
+ // taken from this table rather than from the name, so no casing,
692
+ // Unicode lookalike or double-extension in an attacker-supplied
693
+ // filename can become the extension on disk. A payload renamed to
694
+ // .xml is refused unless drawio's own parser recognises a root it
695
+ // writes (<mxfile> or <mxGraphModel>).
696
+ const DRAWIO_EXT = { '.drawio': '.drawio', '.xml': '.xml' };
697
+ let ext = assetLib.extFor(body.mime);
698
+ if (!ext) {
699
+ const rawName = String(body.name === null || body.name === undefined ? '' : body.name);
700
+ const m = /\.(drawio|xml)$/i.exec(rawName);
701
+ const candidate = m ? DRAWIO_EXT['.' + m[1].toLowerCase()] : null;
702
+ if (!candidate) return send(res, 400, { error: 'unsupported image type' });
703
+ // `buf` is what lands on disk, so `buf` is what gets checked --
704
+ // never a re-decode of `body.data`, which could differ.
705
+ if (!drawioLib.isDrawioXml(buf.toString('utf8'))) {
706
+ return send(res, 400, {
707
+ error: 'refused: this file is not a draw.io diagram (no <mxfile> or <mxGraphModel> root)',
708
+ });
709
+ }
710
+ ext = candidate;
711
+ }
712
+
679
713
  const baseDir = path.dirname(file);
680
714
  const assetsDir = path.join(baseDir, 'assets');
681
715
  fs.mkdirSync(assetsDir, { recursive: true });