From f0d2f495ddafd6d7dd8552abadd086b68d2fb1cc Mon Sep 17 00:00:00 2001 From: ly-wang19 Date: Wed, 24 Jun 2026 20:41:27 +0800 Subject: [PATCH] fix(canvas): stop self-closing embed from starting a greedy block match extractCanvasShortcodes used a block regex whose open-tag attrs group allowed a trailing slash, so a self-closing "[embed ... /]" open tag could start a block embed match. The block pattern then greedily swallowed all visible text up to a later stray "[/embed]", deleting that text from channel delivery. Anchor the block open-tag group so the attrs cannot end with a slash, which prevents a self-closing open tag from initiating a block match. The self-closing shortcode is now matched only by selfClosingRe and the surrounding visible text is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/chat/canvas-render.test.ts | 38 ++++++++++++++++++++++++++++++++++ src/chat/canvas-render.ts | 5 ++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/chat/canvas-render.test.ts diff --git a/src/chat/canvas-render.test.ts b/src/chat/canvas-render.test.ts new file mode 100644 index 000000000000..c7945195797f --- /dev/null +++ b/src/chat/canvas-render.test.ts @@ -0,0 +1,38 @@ +// Canvas-render tests cover [embed] shortcode extraction and text stripping. +import { describe, expect, it } from "vitest"; +import { extractCanvasShortcodes } from "./canvas-render.ts"; + +describe("extractCanvasShortcodes", () => { + it("does not let a self-closing embed start a greedy block match", () => { + // Regression: the block regex used to greedily swallow the span from a + // self-closing "[embed ... /]" open tag up to a later stray "[/embed]", + // deleting the visible text in between (" keep me ") from channel delivery. + const input = '[embed url="https://a.com" /] keep me [/embed]'; + const { text, previews } = extractCanvasShortcodes(input); + + expect(previews).toHaveLength(1); + expect(previews[0]?.url).toBe("https://a.com"); + // The visible text between the self-closing embed and the stray close + // marker must be preserved, not silently stripped. + expect(text).toContain("keep me"); + expect(text).toBe("keep me [/embed]"); + }); + + it("still extracts a normal block embed and strips only the shortcode span", () => { + const input = 'before [embed ref="doc1"] hi [/embed] after'; + const { text, previews } = extractCanvasShortcodes(input); + + expect(previews).toHaveLength(1); + expect(previews[0]?.viewId).toBe("doc1"); + expect(text).toBe("before after"); + }); + + it("still extracts a plain self-closing embed and keeps surrounding text", () => { + const input = 'see [embed url="https://b.com" /] end'; + const { text, previews } = extractCanvasShortcodes(input); + + expect(previews).toHaveLength(1); + expect(previews[0]?.url).toBe("https://b.com"); + expect(text).toBe("see end"); + }); +}); diff --git a/src/chat/canvas-render.ts b/src/chat/canvas-render.ts index c97e15f0baad..167d2c11b400 100644 --- a/src/chat/canvas-render.ts +++ b/src/chat/canvas-render.ts @@ -203,7 +203,10 @@ export function extractCanvasShortcodes(text: string | undefined): { attrs: Record; body?: string; }> = []; - const blockRe = /\[embed\s+([^\]]*?)\]([\s\S]*?)\[\/embed\]/gi; + // Exclude a self-closing open tag ("[embed ... /]") from starting a block + // match by requiring the attrs group not to end with a slash; otherwise the + // block regex greedily swallows visible text up to a later stray [/embed]. + const blockRe = /\[embed\s+([^\]]*?[^\]/]|)\]([\s\S]*?)\[\/embed\]/gi; const selfClosingRe = /\[embed\s+([^\]]*?)\/\]/gi; for (const re of [blockRe, selfClosingRe]) { let match: RegExpExecArray | null;