diff --git a/docs/markdown-renderer.md b/docs/markdown-renderer.md index 269db3da9a..462c230157 100644 --- a/docs/markdown-renderer.md +++ b/docs/markdown-renderer.md @@ -6,15 +6,15 @@ Native selection belongs to the library's text view. Keep an assistant message i ## Links and images -Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. +Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library emits a URL without syntax context, so the renderer recovers inline-code context from source when tapped to preserve suffix lookup and literal percent characters. Keep this parsing off reveal frames. -Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. +Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. ## Engine constraints The pinned nightly has the same React Native package and core source trees as upstream commit `73ac5085eb1fdc743973b3473d4dba65abad5261`. Compatibility was checked with React Native 0.81.5. Native dependencies must be rebuilt when changing the highlighting configuration; JavaScript refresh cannot enable compiled grammars. -Math and video are disabled. Task lists and admonitions are enabled; task checkboxes are read-only. Source adaptation escapes spoiler delimiters and decodes entity text before the engine can discard it. Code spans and fences keep their literal source. Mermaid renders as a code block. HTML and details collapsibles have no custom renderer. +Math and video are disabled. Task lists and admonitions are enabled; task checkboxes are read-only. Source adaptation spaces adjacent empty GFM table delimiters before escaping prose spoiler delimiters and decodes entity text before the engine can discard it. Code spans and fences keep their literal source. Mermaid renders as a code block. HTML and details collapsibles have no custom renderer. Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also preserves web list numbering and link titles, and marks pending parses with `aria-busy` so Find waits for rendered text. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it. diff --git a/package-lock.json b/package-lock.json index 45e1f8fca2..0eba9e0fd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36417,7 +36417,9 @@ "lucide-react-native": "^1.50.0", "markdown-it": "^10.0.0", "mdast-util-from-markdown": "2.0.3", + "mdast-util-gfm-table": "^2.0.0", "mermaid": "^11.16.0", + "micromark-extension-gfm-table": "^2.1.2", "micromark-util-decode-string": "2.0.1", "mnemonic-id": "^3.2.7", "qrcode": "^1.5.4", @@ -37287,6 +37289,23 @@ } } }, + "packages/app/node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "packages/app/node_modules/miniflare": { "version": "4.20260625.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz", diff --git a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts index d664c1dcf4..b6fbadb3ae 100644 --- a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts +++ b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts @@ -252,9 +252,12 @@ test("reloading a timeline reserves image height without a load-time scroll jump scrollTop: number; }>; const loadedIndex = geometryEvents.findIndex((event) => event.complete && event.height > 0); - expect(loadedIndex, JSON.stringify(events)).toBeGreaterThan(0); - const beforeLoad = geometryEvents[loadedIndex - 1]!; + expect(loadedIndex, JSON.stringify(events)).toBeGreaterThanOrEqual(0); + const beforeLoad = geometryEvents[Math.max(0, loadedIndex - 1)]!; const loaded = geometryEvents[loadedIndex]!; + expect( + new Set(geometryEvents.filter((event) => event.height > 0).map((event) => event.height)).size, + ).toBe(1); expect(loaded.height).toBe(beforeLoad.height); expect(loaded.scrollTop).toBe(beforeLoad.scrollTop); }); @@ -297,3 +300,26 @@ test("a near-10 MiB real assistant PNG renders and the app remains responsive", response: "stress-update-0", }); }); + +test("a later reply reads new bytes at the same workspace image path", async ({ + imageWorkspace: workspace, + page, +}) => { + const agent = await createSettledMockAgent(workspace, "Screenshot revisions"); + const first = await createSmallAssistantPng(workspace, { + alt: "Earlier screenshot", + fileName: "screenshot.png", + }); + await emitSettledAssistantImage(workspace.client, agent, first); + await openAssistantImageTimeline(page, agent); + await expectAssistantImageRendered(page, first); + const second = await createSmallAssistantPng(workspace, { + alt: "Updated screenshot", + fileName: "screenshot.png", + width: 2, + height: 3, + }); + await emitSettledAssistantImage(workspace.client, agent, second); + await expectAssistantImageRendered(page, second); + await expectAssistantImageRendered(page, first); +}); diff --git a/packages/app/e2e/browser/plan-card-markdown.spec.ts b/packages/app/e2e/browser/plan-card-markdown.spec.ts index eb58909c6a..660e7a5a5f 100644 --- a/packages/app/e2e/browser/plan-card-markdown.spec.ts +++ b/packages/app/e2e/browser/plan-card-markdown.spec.ts @@ -33,9 +33,9 @@ test.describe("Plan card markdown", () => { test("chat and file preview share the library renderer and resolve file links", async ({ page, }, testInfo) => { - const { writeFile } = await import("node:fs/promises"); + const { writeFile, mkdir } = await import("node:fs/promises"); const source = - "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key | Value |\n| --- | --- |\n| Answer | 42 |\n\nOpen `README.md`."; + "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key || Value |\n| --- | --- | --- |\n| Answer || 42 |\n\nOpen `README.md` or `src/说明.md:2-3`."; const session = await seedMockAgentWorkspace({ repoPrefix: "shared-markdown-", title: "Shared Markdown", @@ -44,6 +44,11 @@ test("chat and file preview share the library renderer and resolve file links", }); try { await writeFile(`${session.cwd}/README.md`, source); + await mkdir(`${session.cwd}/packages/app/src`, { recursive: true }); + await writeFile( + `${session.cwd}/packages/app/src/说明.md`, + "# Nested file\n\nUnicode suffix lookup succeeded.", + ); await session.client.waitForFinish(session.agentId, 30_000); await openAgentRoute(page, session); const message = page.getByTestId("assistant-message"); @@ -52,10 +57,16 @@ test("chat and file preview share the library renderer and resolve file links", await expect(message.locator(".enrm-text > p").nth(1)).toContainText("Second paragraph."); await expect(message.locator("blockquote")).toContainText("A quote"); await expect(message.getByRole("table")).toContainText("42"); + await expect(message.getByRole("cell").nth(0)).toHaveText("Answer"); + await expect(message.getByRole("cell").nth(1)).toHaveText(""); + await expect(message.getByRole("cell").nth(2)).toHaveText("42"); await message.getByRole("link", { name: "README.md" }).click(); const preview = page.getByTestId("markdown-preview-frame"); await expect(preview.locator(".enrm-text")).toContainText("Second paragraph."); await expect(preview.getByRole("table")).toContainText("42"); + await page.getByRole("button", { name: "Shared Markdown", exact: true }).click(); + await message.getByRole("link", { name: "src/说明.md:2-3" }).click(); + await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible(); await testInfo.attach("shared-renderer", { body: await page.screenshot(), contentType: "image/png", diff --git a/packages/app/e2e/support/helpers/assistant-images.ts b/packages/app/e2e/support/helpers/assistant-images.ts index c9ca6d2ebf..85409d1123 100644 --- a/packages/app/e2e/support/helpers/assistant-images.ts +++ b/packages/app/e2e/support/helpers/assistant-images.ts @@ -26,16 +26,13 @@ export interface AssistantImageFixture { export async function createSmallAssistantPng( workspace: SeededWorkspace, - input: { alt: string; fileName: string }, + input: { alt: string; fileName: string; width?: number; height?: number }, ): Promise { - await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG); - return { - alt: input.alt, - height: 1, - relativePath: input.fileName, - size: SMALL_PNG.byteLength, - width: 1, - }; + const width = input.width ?? 1; + const height = input.height ?? 1; + const bytes = width === 1 && height === 1 ? SMALL_PNG : encodeDeterministicPng(width, height); + await writeFile(path.join(workspace.repoPath, input.fileName), bytes); + return { alt: input.alt, height, relativePath: input.fileName, size: bytes.byteLength, width }; } export async function createNearTenMegabyteAssistantPng( diff --git a/packages/app/package.json b/packages/app/package.json index 9d0d90feec..359814a3ad 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -105,7 +105,9 @@ "lucide-react-native": "^1.50.0", "markdown-it": "^10.0.0", "mdast-util-from-markdown": "2.0.3", + "mdast-util-gfm-table": "^2.0.0", "mermaid": "^11.16.0", + "micromark-extension-gfm-table": "^2.1.2", "micromark-util-decode-string": "2.0.1", "mnemonic-id": "^3.2.7", "qrcode": "^1.5.4", diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index c4078ad332..832e0ab7d4 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -2,7 +2,7 @@ export { classifyAssistantFileLink, normalizeInlinePathTarget, type InlinePathTarget, -} from "./parse"; +} from "./paths"; export { AssistantFileLinkResolverProvider, useAssistantFileLinkEnvironment, diff --git a/packages/app/src/assistant-file-links/parse.test.ts b/packages/app/src/assistant-file-links/paths/index.test.ts similarity index 99% rename from packages/app/src/assistant-file-links/parse.test.ts rename to packages/app/src/assistant-file-links/paths/index.test.ts index 3390d0cfa9..e09640f3f5 100644 --- a/packages/app/src/assistant-file-links/parse.test.ts +++ b/packages/app/src/assistant-file-links/paths/index.test.ts @@ -5,7 +5,7 @@ import { parseAssistantFileLink, parseFileProtocolUrl, parseInlinePathToken, -} from "./parse"; +} from "./index"; describe("parseInlinePathToken", () => { it("returns null for plain paths without a line number", () => { diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/paths/index.ts similarity index 100% rename from packages/app/src/assistant-file-links/parse.ts rename to packages/app/src/assistant-file-links/paths/index.ts diff --git a/packages/app/src/assistant-file-links/provider.tsx b/packages/app/src/assistant-file-links/provider.tsx index 967e1c32e3..479ce2a635 100644 --- a/packages/app/src/assistant-file-links/provider.tsx +++ b/packages/app/src/assistant-file-links/provider.tsx @@ -10,7 +10,7 @@ import { import React from "react"; import type { ToastApi } from "@/components/toast-host"; import type { OpenFileDisposition } from "@/workspace/file-open"; -import type { InlinePathTarget } from "./parse"; +import type { InlinePathTarget } from "./paths"; import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver"; export interface AssistantFileLinkDaemonClient { diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index 774d39278b..85dc685256 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -3,7 +3,7 @@ import { isFileLookingAssistantToken, type AssistantFileLinkClassification, type InlinePathTarget, -} from "./parse"; +} from "./paths"; import { i18n } from "@/i18n/i18next"; export interface AssistantFileLinkSource { diff --git a/packages/app/src/assistant-file-links/use-file-link.test.tsx b/packages/app/src/assistant-file-links/use-file-link.test.tsx index 386cb429f1..e336f12e67 100644 --- a/packages/app/src/assistant-file-links/use-file-link.test.tsx +++ b/packages/app/src/assistant-file-links/use-file-link.test.tsx @@ -6,7 +6,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import React, { useCallback, useMemo, useState, type ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import type { ToastApi } from "@/components/toast-host"; -import type { InlinePathTarget } from "./parse"; +import type { InlinePathTarget } from "./paths"; import { AssistantFileLinkResolverProvider } from "./provider"; import type { DirectorySuggestionResult } from "./resolver"; import { useFileLink } from "./use-file-link"; diff --git a/packages/app/src/assistant-file-links/use-file-link.ts b/packages/app/src/assistant-file-links/use-file-link.ts index 603645f0fe..401c52d70f 100644 --- a/packages/app/src/assistant-file-links/use-file-link.ts +++ b/packages/app/src/assistant-file-links/use-file-link.ts @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useStableEvent } from "@/hooks/use-stable-event"; import type { OpenFileDisposition } from "@/workspace/file-open"; import { openExternalUrl } from "@/utils/open-external-url"; -import type { InlinePathTarget } from "./parse"; +import type { InlinePathTarget } from "./paths"; import { useAssistantFileLinkResolverContext, type AssistantFileLinkResolverContextValue, diff --git a/packages/app/src/components/markdown/images.ts b/packages/app/src/components/markdown/images.ts index 703aa2dbed..a551d149ae 100644 --- a/packages/app/src/components/markdown/images.ts +++ b/packages/app/src/components/markdown/images.ts @@ -1,4 +1,4 @@ -import { useMemo, useEffect } from "react"; +import { useMemo, useEffect, useId } from "react"; import { useFetchQueries } from "@/data/query"; import { createAssistantImageFileAcquisition } from "@/assistant-image/file-acquisition"; import { @@ -33,7 +33,11 @@ export interface MarkdownImageContext { } export function useMarkdownSource(text: string, context: MarkdownImageContext) { - const prepared = useMemo(() => prepareMarkdownSource(text), [text]); + const occurrenceKey = useId(); + const prepared = useMemo( + () => prepareMarkdownSource(text, context.workspaceRoot), + [text, context.workspaceRoot], + ); const sources = [...new Set(prepared.images.map((image) => image.source))]; const acquisitions = sources.map((source) => { const resolved = context.filePath @@ -54,7 +58,7 @@ export function useMarkdownSource(text: string, context: MarkdownImageContext) { ? resolveAssistantImageSource({ source: resolved, workspaceRoot: context.workspaceRoot }) : null, serverId: context.serverId, - occurrenceKey: context.filePath ?? context.workspaceRoot ?? "markdown", + occurrenceKey, unavailableMessage: "Image unavailable", }); }); @@ -90,6 +94,9 @@ export function useMarkdownSource(text: string, context: MarkdownImageContext) { }); return { markdown: prepared.render(resolved), + linkRecognition: prepared.linkRecognition, + linkPillContent: prepared.linkPillContent, + linkSource: prepared.linkSource, imageSources: Object.fromEntries([...resolved].map(([source, url]) => [url, source])), }; } diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index a28a959ae0..9f335a6604 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -36,3 +36,30 @@ it("preserves named and numeric entities as literal rendered text", () => { "AT\\&T 😀 \\# heading and `&`", ); }); + +it("preserves empty table cells while escaping spoilers in cell text", () => { + const source = "| A || B |\n| --- | --- | --- |\n| x || z |"; + expect(prepareMarkdownSource(source).render()).toBe( + "| A | | B |\n| --- | --- | --- |\n| x | | z |", + ); +}); + +it.each([ + "src/components/message.tsx", + "C:\\repo\\file.ts", + "docs/说明.md", + "src/file.ts:10-20", + "src/file.ts(10,2)", +])("recognizes inline paths through the file resolver: %s", (path) => { + const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/workspace"); + expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true); + expect(prepared.linkSource(path)).toEqual({ href: path, text: path, sourceType: "inline-code" }); +}); + +it("recognizes bare Unicode filenames and preserves inline-code URLs", () => { + const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.", "/workspace"); + expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([ + "说明.md", + ]); + expect(prepared.linkRecognition.inlineCode?.test("https://example.com")).toBe(true); +}); diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index e5fb52fbb1..8c9f2301c0 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -1,6 +1,6 @@ import { View } from "react-native"; import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup"; -import { memo, useMemo, useState } from "react"; +import { memo, useState } from "react"; import { EnrichedMarkdownText, type MarkdownStyle, @@ -20,12 +20,6 @@ import { useMarkdownSource, type MarkdownImageContext } from "./images"; const ThemedMarkdown = withUnistyles(EnrichedMarkdownText); const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true }; -const FILE_PATTERN = - /(?:https?:\/\/[^\s<>]+|(?:\.{0,2}\/|~\/)?[\w@.-]+(?:\/[\w@.-]+)+(?::\d+(?::\d+)?)?|[\w@-]+\.[a-zA-Z][\w-]*(?::\d+(?::\d+)?)?)/g; -const LINK_RECOGNITION = { - text: FILE_PATTERN, - inlineCode: new RegExp(`^(?:${FILE_PATTERN.source})$`), -}; function markdownStyle(theme: Theme, compact = false): { markdownStyle: MarkdownStyle } { const fontSize = compact ? theme.fontSize.base : theme.fontSize.content; @@ -140,15 +134,16 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ (environment.client?.readFile ? { readFile: environment.client.readFile.bind(environment.client) } : undefined); - const { markdown, imageSources } = useMarkdownSource(text, { - workspaceRoot: environment.workspaceRoot, - serverId: environment.serverId, - ...imageContext, - client, - }); + const { markdown, imageSources, linkRecognition, linkPillContent, linkSource } = + useMarkdownSource(text, { + workspaceRoot: environment.workspaceRoot, + serverId: environment.serverId, + ...imageContext, + client, + }); const [image, setImage] = useState(null); const pressLink = useStableEvent(({ url }: { url: string }) => { - const source = { href: url, text: url }; + const source = linkSource(url); if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url); else actions.open(source, "preferred"); }); @@ -156,15 +151,6 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ setImage({ type: "uri", uri: url }), ); const closeImage = useStableEvent(() => setImage(null)); - const pills = useMemo( - () => - Object.fromEntries( - [...markdown.matchAll(FILE_PATTERN)] - .filter(([url]) => !/^https?:/i.test(url)) - .map(([url]) => [url, { label: url }]), - ), - [markdown], - ); const content = ( <> ]+|(?:[A-Za-z]:[\\/]|\.{0,2}\/|~\/)?[a-zA-Z0-9\u0080-\uFFFF_@.%+-]+(?:[\\/][a-zA-Z0-9\u0080-\uFFFF_@.%+-]+)+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?|[a-zA-Z0-9\u0080-\uFFFF_@-]+\.[a-zA-Z0-9\u0080-\uFFFF_-]+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?)/g; + +function linkMetadata(source: string, getTree: () => Root, workspaceRoot?: string) { + return { + linkPillContent: Object.fromEntries( + [...source.matchAll(FILE_PATTERN)] + .filter(([url]) => !/^https?:/i.test(url)) + .map(([url]) => [url, { label: url }]), + ), + linkRecognition: { + text: FILE_PATTERN, + inlineCode: new RegExp( + String.raw`^(?:${FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`, + ), + }, + linkSource(url: string): AssistantFileLinkSource { + function containsInlinePath(node: Nodes): boolean { + return ( + (node.type === "inlineCode" && node.value === url) || + ("children" in node && node.children.some(containsInlinePath)) + ); + } + // The library emits only the URL. Recover raw inline-code semantics at tap time, + // without parsing every reveal frame merely to configure link recognition. + return source.includes("`") && + classifyAssistantFileLink(url, { workspaceRoot }) && + containsInlinePath(getTree()) + ? { href: url, text: url, sourceType: "inline-code" } + : { href: url, text: url }; + }, + }; +} export interface MarkdownImage { source: string; @@ -11,23 +51,37 @@ export interface MarkdownImage { } /** Preserve source spelling; adapt workspace images, spoiler delimiters, and entity text. */ -export function prepareMarkdownSource(source: string) { +export function prepareMarkdownSource(source: string, workspaceRoot?: string) { const images: MarkdownImage[] = []; + let tree: Root | undefined; + const getTree = () => + (tree ??= fromMarkdown(source, { + extensions: [gfmTable()], + mdastExtensions: [gfmTableFromMarkdown()], + })); const edits: { start: number; end: number; text: string }[] = []; if (!source.includes("![") && !source.includes("||") && !source.includes("&")) { - return { images, render: () => source }; + return { images, ...linkMetadata(source, getTree, workspaceRoot), render: () => source }; } - const tree = fromMarkdown(source); + const definitions = new Map(); function definitionsIn(node: Nodes) { if (node.type === "definition") definitions.set(node.identifier, node); if ("children" in node) node.children.forEach(definitionsIn); } - definitionsIn(tree); + definitionsIn(getTree()); function visit(node: Nodes) { const start = node.position?.start.offset; const end = node.position?.end.offset; if (start !== undefined && end !== undefined) { + if (node.type === "tableRow") { + // MD4C's always-enabled spoiler extension otherwise consumes adjacent cell + // delimiters. Spaces keep empty GFM cells empty without producing spoilers. + const row = source.slice(start, end); + for (const match of row.matchAll(/(? = new Map()) { const replacements = [...edits]; for (const image of images) {