dsh-markdown-bubble 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -95,6 +95,8 @@ const a = 1
95
95
 
96
96
  ### 修复记录
97
97
 
98
+ - **0.1.3** —— 把「座位 render 绝不抛」这条不变式补完:0.1.2 守住了合成与文案查询,但仍有形状会在**座位自己的渲染里**抛错(`node` 缺失 → `node.data`、`props` 缺失 → 解构本身、`new Date(time)` 遇到 Symbol / BigInt / 抛错的 valueOf、宿主图片渲染器抛错)。这些同样冒泡到槽位机制、退掉整条条目,症状与 0.1.2 修的完全一样。现在三层兜底:座位本体一层 try 并把 `node` / `data` 的取用改成防御式;时钟与附件卡在各自调用点兜底;最后一层 `renderPlainRow` 保证最坏情况只是这一行退回纯文本,而操作条与兄弟插件锚点仍在。新增回归:21 种恶意宿主形状逐一断言不抛、且行与操作条都在。
99
+ - **0.1.2** — 座位组件不再可能因渲染期异常被槽位机制摘除:`composeUserMarkdown` / `buildMarkdownLabels` 在座位渲染过程中运行,抛错不会被错误边界接住(错误边界只能接住其后代,接不住创建它的那次渲染),会冒泡到槽位机制并把该条目整条退掉——屏幕上就是气泡交还给宿主、Markdown 变回源码。现在合成与所有文案查询都在座位内部兜底:任何宿主形状不符(节点结构异常、投影返回不可识别、locale 席位不可调用)都退回宿主原始投影,气泡外壳、操作条与兄弟插件锚点全部保留。`data.content` 非数组、`t` 不可调用等情形一并加进回归测试。
98
100
  - **0.1.1** — 修复两个引用 chip 之间只有空白时该空白被当作空 Markdown 块丢弃的问题:相邻 chip 不再粘成 `@a@b`,chip 之间的换行也按宿主 `pre-wrap` 气泡的样子保留为换行(空白段渲染为 `data-dshmb-gap` 的预换行节点,仍不产生空 Markdown 块);自建的操作条 / 复制按钮补上 `data-dshmb-actions` / `data-dshmb-action` 命名空间标记;座位优先级 `-1` 的宿主依据(宿主默认 0、最低者渲染、同优先级抛错)与三个兄弟插件依赖的锚点结构一并加进回归测试。
99
101
 
100
102
  ### License
@@ -177,6 +179,8 @@ Queued-message previews, the composer itself and the goal panel stay plain text
177
179
 
178
180
  ### Fixes
179
181
 
182
+ - **0.1.3** — completes the "the seat render never throws" invariant: 0.1.2 guarded composition and the copy lookups, but shapes still threw in the seat's own render (an absent `node` -> `node.data`, absent props -> the destructuring itself, a `time` the Date constructor refuses — Symbol, BigInt, a `valueOf` that throws — and a host image renderer that throws). Each escaped to the slot machinery and retired the entry, the same symptom 0.1.2 fixed. Three layers now: one try around the seat body with defensive `node` / `data` access, per-call guards for the clock and the attachment card, and a last-resort `renderPlainRow` so the worst case is one row falling back to plain text with its action strip and the sibling anchors intact. Regression: 21 hostile host shapes, each asserted not to throw and to keep the row and its strip.
183
+ - **0.1.2** — the seat component can no longer be retired by a render-time exception: `composeUserMarkdown` / `buildMarkdownLabels` run while the seat renders, and a throw there is not caught by the error boundary (a boundary catches its descendants, not the render that creates it) — it escapes to the slot machinery, which drops the whole entry and hands the row back to the host, so the bubble reverts to raw source on screen. Composition and every copy lookup now degrade inside the seat: any host-shape mismatch (an unexpected node, an unrecognised projection result, a locale seat that is not callable) falls back to the host's plain projection while the bubble shell, the action strip and the sibling-plugin anchors stay intact. `data.content` that is not an array and an uncallable `t` are covered by regression tests.
180
184
  - **0.1.1** — fixed whitespace-only plain runs between two reference chips being dropped as an empty Markdown block: adjacent chips no longer glue into `@a@b`, and a newline between two chips stays a line break the way the host's `pre-wrap` bubble shows it (the run renders as a `data-dshmb-gap` pre-wrap node, still never an empty Markdown block); the self-created action strip and copy button now carry the `data-dshmb-actions` / `data-dshmb-action` namespace markers; and the host evidence behind `priority: -1` (host default 0, lowest renders, same priority throws) plus the anchor structure the three sibling plugins rely on are covered by regression tests.
181
185
 
182
186
  ### License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-markdown-bubble",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Markdown rendering for DeepSeek Harness sent user messages: every message bubble renders lists, code, tables, quotes and inline formatting through the host's own Markdown pipeline, while queues, previews and the composer keep their plain projection.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -49,7 +49,7 @@ window.__ModuleLoader__.load({
49
49
  const NS = 'dsh-markdown-bubble'
50
50
 
51
51
  /** Keep in sync with package.json and src/index.js. */
52
- const PLUGIN_VERSION = '0.1.1'
52
+ const PLUGIN_VERSION = '0.1.3'
53
53
 
54
54
  /** Presence marker the live verifier reads. */
55
55
  const DEBUG_KEY = '__DSH_MARKDOWN_BUBBLE__'
@@ -230,6 +230,25 @@ window.__ModuleLoader__.load({
230
230
  return react.isValidElement(piece) && typeof piece.props.children === 'string' ? piece.props.children : ''
231
231
  }
232
232
 
233
+ /**
234
+ * A copy lookup that cannot take the seat down. The framework injects a
235
+ * `t` for the declared `chat` namespace, but a host-shape mismatch (a
236
+ * locale service that was never bound, a renamed key) must fall back to
237
+ * the key rather than throw during render.
238
+ * @param t - the injected chat translator, possibly not callable.
239
+ * @param key - the dictionary key.
240
+ * @param params - template parameters.
241
+ * @returns the translated string, or the key.
242
+ */
243
+ function safeTranslate(t, key, params) {
244
+ if (typeof t !== 'function') return key
245
+ try {
246
+ return t(key, params)
247
+ } catch {
248
+ return key
249
+ }
250
+ }
251
+
233
252
  /** The host projection, called in a guard: it is the fallback of last resort. */
234
253
  function plainProjection(text, referenceLabels, skillNames, references) {
235
254
  try {
@@ -240,6 +259,36 @@ window.__ModuleLoader__.load({
240
259
  }
241
260
  }
242
261
 
262
+ /**
263
+ * Compose the markdown body without ever throwing.
264
+ *
265
+ * `composeUserMarkdown` runs while the seat component renders, so a throw
266
+ * here does NOT reach {@link MarkdownBoundary} — a boundary catches its
267
+ * descendants, not the parent render that creates it. It would escape to
268
+ * the slot machinery, which answers by retiring the whole entry and handing
269
+ * the row back to the host's raw renderer; on screen that is
270
+ * indistinguishable from "the plugin stopped working". A host-shape
271
+ * mismatch (an unexpected node shape, an unrecognised projection result)
272
+ * therefore falls back to the plain projection right here, inside our own
273
+ * render, so the bubble keeps its shell, its actions and the sibling
274
+ * plugins' injection anchors.
275
+ * @param text - the message text.
276
+ * @param t - the injected chat translator.
277
+ * @param referenceLabels - session recall labels.
278
+ * @param skillNames - skill names.
279
+ * @param references - file/skill open callbacks.
280
+ * @returns the composed body, or null when markdown cannot be produced.
281
+ */
282
+ function safeMarkdownBody(text, t, referenceLabels, skillNames, references) {
283
+ try {
284
+ const labels = buildMarkdownLabels(t)
285
+ return composeUserMarkdown(text, labels, referenceLabels, skillNames, references)
286
+ } catch (error) {
287
+ console.warn('[' + NS + '] markdown composition failed; showing the plain projection', error)
288
+ return null
289
+ }
290
+ }
291
+
243
292
  /**
244
293
  * A whitespace-only plain run: the gap between two chips, or the leading /
245
294
  * trailing padding the host's own pre-wrap bubble shows. It carries no
@@ -330,15 +379,15 @@ window.__ModuleLoader__.load({
330
379
  function buildMarkdownLabels(t) {
331
380
  return {
332
381
  code: {
333
- copyLabel: t('copy'),
334
- copiedLabel: t('copied'),
382
+ copyLabel: safeTranslate(t, 'copy'),
383
+ copiedLabel: safeTranslate(t, 'copied'),
335
384
  toolbarLabels: {
336
- codeLabel: t('codeBlock.title'),
337
- wrapLabel: t('codeBlock.wrap'),
338
- unwrapLabel: t('codeBlock.unwrap'),
385
+ codeLabel: safeTranslate(t, 'codeBlock.title'),
386
+ wrapLabel: safeTranslate(t, 'codeBlock.wrap'),
387
+ unwrapLabel: safeTranslate(t, 'codeBlock.unwrap'),
339
388
  },
340
389
  },
341
- footnotes: t('markdown.footnotes'),
390
+ footnotes: safeTranslate(t, 'markdown.footnotes'),
342
391
  }
343
392
  }
344
393
 
@@ -385,6 +434,23 @@ window.__ModuleLoader__.load({
385
434
  return day
386
435
  }
387
436
 
437
+ /**
438
+ * Log one degradation per distinct reason.
439
+ *
440
+ * The seat renders on every transcript update, so a hostile host shape would
441
+ * otherwise print the same warning on every pass and bury the one line that
442
+ * matters. Keyed by the reason rather than by the row: what a reader needs to
443
+ * know is which guard fired, once.
444
+ * @param reason - one line naming what degraded.
445
+ * @param error - the caught value.
446
+ */
447
+ const warnedReasons = new Set()
448
+ function warnOnce(reason, error) {
449
+ if (warnedReasons.has(reason)) return
450
+ warnedReasons.add(reason)
451
+ console.warn('[' + NS + '] ' + reason, error)
452
+ }
453
+
388
454
  /**
389
455
  * The host's message clock: `HH:mm` for today, the `clock.md` date plus
390
456
  * the clock earlier this year, the `clock.ymd` form for other years.
@@ -394,12 +460,25 @@ window.__ModuleLoader__.load({
394
460
  * @returns the display string.
395
461
  */
396
462
  function formatMessageClock(time, t, now) {
397
- const date = new Date(time)
398
- const reference = new Date(now)
463
+ // `new Date(x)` throws on a Symbol, a BigInt, or an object whose `valueOf`
464
+ // throws — and the host hands `data.time` through unchanged. A throw here
465
+ // happens INSIDE the seat's subtree, outside MarkdownBoundary (the strip is
466
+ // not the markdown body), so it would escape to the slot machinery and
467
+ // retire the whole entry. A missing clock is a cosmetic loss; a retired
468
+ // renderer is "the plugin stopped working".
469
+ let date
470
+ let reference
471
+ try {
472
+ date = new Date(time)
473
+ reference = new Date(now)
474
+ } catch (error) {
475
+ warnOnce('the message time cannot be read; the clock is hidden', error)
476
+ return ''
477
+ }
399
478
  const clock = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
400
479
  if (date.getFullYear() === reference.getFullYear() && date.getMonth() === reference.getMonth() && date.getDate() === reference.getDate()) return clock
401
480
  const params = { y: date.getFullYear(), m: date.getMonth() + 1, d: date.getDate() }
402
- return `${date.getFullYear() === reference.getFullYear() ? t('clock.md', params) : t('clock.ymd', params)} ${clock}`
481
+ return `${date.getFullYear() === reference.getFullYear() ? safeTranslate(t, 'clock.md', params) : safeTranslate(t, 'clock.ymd', params)} ${clock}`
403
482
  }
404
483
 
405
484
  // --- pieces -----------------------------------------------------------------
@@ -477,7 +556,7 @@ window.__ModuleLoader__.load({
477
556
 
478
557
  const clock =
479
558
  time === undefined ? null : h('span', { className: 'dshmb-timeStart' }, formatMessageClock(time, t, day))
480
- const label = copied ? t('copied') : t('copy')
559
+ const label = copied ? safeTranslate(t, 'copied') : safeTranslate(t, 'copy')
481
560
 
482
561
  return h(
483
562
  'div',
@@ -501,22 +580,36 @@ window.__ModuleLoader__.load({
501
580
  * @param props - the attachment entry and the owner's image renderer.
502
581
  * @returns the row entry.
503
582
  */
504
- function AttachmentEntry({ attachment, renderMessageImages, compactImages }) {
583
+ function AttachmentEntry(props) {
584
+ // The image branch calls the HOST's renderer during our render, and the
585
+ // file branch calls host primitives (`fileExtension`, `fileSizeText`). Both
586
+ // sit outside MarkdownBoundary, so either can retire the entry. One
587
+ // attachment is worth losing; the row is not.
588
+ try {
589
+ return renderAttachmentEntry(props)
590
+ } catch (error) {
591
+ warnOnce('an attachment could not be rendered; it is skipped', error)
592
+ return null
593
+ }
594
+ }
595
+
596
+ function renderAttachmentEntry({ attachment, renderMessageImages, compactImages }) {
505
597
  if (attachment.type === 'image') {
506
598
  return h(Fragment, null, renderMessageImages({ images: [attachment.image], align: 'end', compact: compactImages }))
507
599
  }
508
600
  const file = attachment.file ?? {}
509
- const meta = [primitives.fileExtension(file.name).toUpperCase().slice(0, 8), primitives.fileSizeText(file.bytes)]
601
+ const name = typeof file.name === 'string' ? file.name : ''
602
+ const meta = [primitives.fileExtension(name).toUpperCase().slice(0, 8), primitives.fileSizeText(file.bytes)]
510
603
  .filter(Boolean)
511
604
  .join(' ')
512
605
  return h(
513
606
  'span',
514
- { className: 'dshmb-fileCard', title: file.name },
515
- h(primitives.FileTypeIcon, { path: file.name, className: 'dshmb-fileIcon' }),
607
+ { className: 'dshmb-fileCard', title: name },
608
+ h(primitives.FileTypeIcon, { path: name, className: 'dshmb-fileIcon' }),
516
609
  h(
517
610
  'span',
518
611
  { className: 'dshmb-fileContent' },
519
- h('span', { className: 'dshmb-fileName' }, file.name),
612
+ h('span', { className: 'dshmb-fileName' }, name),
520
613
  h('span', { className: 'dshmb-fileMeta' }, meta),
521
614
  ),
522
615
  )
@@ -529,22 +622,46 @@ window.__ModuleLoader__.load({
529
622
  * @param props - keyed chat renderer seat for the `user`/`steering` kinds.
530
623
  * @returns the right-aligned bubble with attachments, markdown, chips and actions.
531
624
  */
532
- const MarkdownBubbleSeat = react.memo(function MarkdownBubbleSeat({
533
- node,
534
- renderMessageImages,
535
- openFile,
536
- openSkill,
537
- t,
538
- }) {
539
- const data = node.data
540
- const { text, attachments, rest } = contentParts(data.content)
541
- const referenceLabels = data.referenceLabels ?? []
542
- const skillNames = data.skillNames ?? []
625
+ const MarkdownBubbleSeat = react.memo(function MarkdownBubbleSeat(props) {
626
+ try {
627
+ return renderMarkdownBubble(props)
628
+ } catch (error) {
629
+ // Last-resort net. Everything that can throw is guarded where it is
630
+ // called (see safeMarkdownBody, plainProjection, formatMessageClock,
631
+ // AttachmentEntry); this exists so an UNKNOWN host shape degrades one
632
+ // row to plain text instead of retiring the renderer. The fallback still
633
+ // renders the action strip, because the three sibling plugins anchor
634
+ // their own buttons on `row.querySelector('[class*="_actions"]')`.
635
+ warnOnce('the markdown seat failed to render; showing the plain text', error)
636
+ return renderPlainRow(props)
637
+ }
638
+ })
639
+
640
+ /**
641
+ * The real seat body. Separated from the memo wrapper so the wrapper can hold
642
+ * one try/catch for the whole synchronous render.
643
+ * @param props - keyed chat renderer seat for the `user`/`steering` kinds.
644
+ * @returns the right-aligned bubble with attachments, markdown, chips and actions.
645
+ */
646
+ function renderMarkdownBubble(props) {
647
+ const safeProps = props !== null && typeof props === 'object' ? props : {}
648
+ const node = safeProps.node !== null && typeof safeProps.node === 'object' ? safeProps.node : {}
649
+ const renderMessageImages = safeProps.renderMessageImages
650
+ const openFile = safeProps.openFile
651
+ const openSkill = safeProps.openSkill
652
+ const t = safeProps.t
653
+ const data = node.data !== null && typeof node.data === 'object' ? node.data : {}
654
+ const { text, attachments, rest } = contentParts(Array.isArray(data.content) ? data.content : [])
655
+ const referenceLabels = Array.isArray(data.referenceLabels) ? data.referenceLabels : []
656
+ const skillNames = Array.isArray(data.skillNames) ? data.skillNames : []
543
657
  const references = react.useMemo(() => ({ openFile, openSkill }), [openFile, openSkill])
544
- const labels = react.useMemo(() => buildMarkdownLabels(t), [t])
545
658
  const compactImages = attachments.length > 1
546
659
  const showBubble = text !== '' || rest.length > 0
547
660
 
661
+ // Never let composition throw out of this render — see safeMarkdownBody.
662
+ const body = safeMarkdownBody(text, t, referenceLabels, skillNames, references)
663
+ const fallback = plainProjection(text, referenceLabels, skillNames, references)
664
+
548
665
  // The action strip leads the row in DOM order and is pushed below the
549
666
  // bubble by flex `order`. Sibling plugins pick their bar with
550
667
  // `row.querySelector('[class*="_actions"]')` — first match in document
@@ -576,17 +693,13 @@ window.__ModuleLoader__.load({
576
693
  h(
577
694
  'div',
578
695
  { className: 'dshmb-bubble' },
579
- h(
580
- MarkdownBoundary,
581
- { key: text, fallback: plainProjection(text, referenceLabels, skillNames, references) },
582
- composeUserMarkdown(text, labels, referenceLabels, skillNames, references),
583
- ),
696
+ h(MarkdownBoundary, { key: text, fallback }, body === null ? fallback : body),
584
697
  rest.map((block, index) =>
585
698
  h(primitives.JsonBlock, {
586
699
  key: 'rest' + index,
587
- label: t('message.extraBlock'),
700
+ label: safeTranslate(t, 'message.extraBlock'),
588
701
  payload: block,
589
- truncatedLabel: (total) => t('json.truncated', { total }),
702
+ truncatedLabel: (total) => safeTranslate(t, 'json.truncated', { total }),
590
703
  }),
591
704
  ),
592
705
  ),
@@ -594,11 +707,39 @@ window.__ModuleLoader__.load({
594
707
  h(
595
708
  'div',
596
709
  { className: 'dshmb-referenceSummary' },
597
- t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) }),
710
+ safeTranslate(t, 'message.referenceSummary', {
711
+ labels: referenceLabels.join(safeTranslate(t, 'message.referenceSeparator')),
712
+ }),
598
713
  ),
599
714
  ),
600
715
  )
601
- })
716
+ }
717
+
718
+ /**
719
+ * The degraded row: the action strip plus the raw text, built so that nothing
720
+ * in it can throw.
721
+ * @param props - whatever the seat was handed.
722
+ * @returns the row with its sibling anchors intact.
723
+ */
724
+ function renderPlainRow(props) {
725
+ const safeProps = props !== null && typeof props === 'object' ? props : {}
726
+ const node = safeProps.node !== null && typeof safeProps.node === 'object' ? safeProps.node : {}
727
+ const data = node.data !== null && typeof node.data === 'object' ? node.data : {}
728
+ let text = ''
729
+ try {
730
+ text = contentParts(Array.isArray(data.content) ? data.content : []).text
731
+ } catch {
732
+ text = ''
733
+ }
734
+ return h(
735
+ 'div',
736
+ { className: 'dshmb-row', 'data-dshmb-row': '1', 'data-dshmb-degraded': '1' },
737
+ h(MessageActions, { text, time: data.time, t: safeProps.t }),
738
+ text === ''
739
+ ? null
740
+ : h('div', { className: 'dshmb-stack' }, h('div', { className: 'dshmb-bubble' }, h('span', { className: 'dshmb-plain' }, text))),
741
+ )
742
+ }
602
743
 
603
744
  // --- style ------------------------------------------------------------------
604
745
 
package/src/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  export const PLUGIN_ID = 'dsh-markdown-bubble'
16
16
 
17
17
  /** Keep in sync with package.json and src/client.js. */
18
- export const PLUGIN_VERSION = '0.1.1'
18
+ export const PLUGIN_VERSION = '0.1.3'
19
19
 
20
20
  /** Cordis plugin name; the bundle patch resolves the package by this row. */
21
21
  export const name = PLUGIN_ID