mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
feat(markdown): use enriched renderer across all platforms
This commit is contained in:
@@ -49,7 +49,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native prose runs, selection boundaries, renderer flavor and library constraints |
|
||||
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
|
||||
@@ -29,26 +29,8 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
|
||||
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
|
||||
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
|
||||
- **Markdown rows belong to presentation, and every assistant message is a block group.**
|
||||
`agent-stream/presentation.ts` keeps the source message whole for plugins, then splits it for
|
||||
display. Native groups completed adjacent prose into selectable runs; web retains individual blocks.
|
||||
The growing last source block stays on the existing block renderer and streaming parser.
|
||||
It joins the preceding run when the next block starts or the turn ends.
|
||||
Code, tables, images, HTML-like content and blockquotes end a native run. See
|
||||
[native text selection](native-text-selection.md) for renderer ownership and library constraints.
|
||||
Appends reparse the growing last block. A completed reference definition at its start can
|
||||
belong to the preceding block, so that case reparses the full message. Completed rows retain
|
||||
object identity and unchanged history stays cached. Prose rows retain their original source
|
||||
blocks so native preparation cannot broaden reference scope. Completed blocks are classified
|
||||
and prepared once; the growing block is neither classified nor prepared for the prose renderer.
|
||||
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}`; native prose runs use `${messageId}:run:${n}` with
|
||||
`blockGroupId = messageId`, including single-row messages. Inline images split a source block into offset-keyed parts, so nothing can come to rely on the two
|
||||
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
|
||||
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
|
||||
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
|
||||
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
|
||||
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
|
||||
occurrence keys.
|
||||
- **One source assistant message is one display row.** Presentation projects plugin output and caches unchanged history. The shared [Markdown renderer](markdown-renderer.md) owns rendering on every platform. Chat paces the visible source and applies its 32,000-character render cap; it does not split Markdown or promote completed blocks into history.
|
||||
- **Message identity survives completion.** Assistant row ids are source message ids. Plugin rows can replace a source, so find, scroll-to-message and history reveal still use `getStreamItemMessageId`. Width-specific measurements describe whole rendered messages.
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Markdown rendering
|
||||
|
||||
Use `MarkdownRenderer` for chat replies, file previews, plans, pull requests and changelog content. It uses `react-native-enriched-markdown` on iOS, Android and web. Keep source adaptation and renderer configuration in `components/markdown`; consumers supply Markdown and, when needed, its file location.
|
||||
|
||||
Native selection belongs to the library's text view. Keep an assistant message in one row so selection can cross paragraphs, headings and lists. Code blocks and tables use the library's native views and their selection behavior. Web uses browser selection over semantic HTML. Chat still owns paced reveal, its render cap, message identity and list measurement; it does not choose a Markdown engine or classify prose.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
The library determines how incomplete Markdown appears during streaming. Completed text uses the same component and engine as history. Find and scroll-to-message address source message ids; selection-copy understands semantic HTML and the web code component's copy metadata.
|
||||
|
||||
## Verification
|
||||
|
||||
Follow [QA](qa.md) and [mobile testing](mobile-testing.md). Exercise chat, file preview, plan and PR content on all three platforms. Include cross-paragraph selection, progressive streaming, a file-link tap, code copy/highlighting, a table and a workspace image. On iPad, repeat preview selection after opening and closing Explorer and after rotation. Check web find, selection-copy and source-message navigation after DOM changes.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Native text selection
|
||||
|
||||
Assistant prose uses `react-native-enriched-markdown` 1.1.1 on iOS and Android. Native selection belongs to one text view, so separate paragraph rows prevent dragging a selection between them.
|
||||
|
||||
Keep completed adjacent paragraphs, headings, lists and horizontal rules in prose runs. The growing last source block uses Paseo’s existing block renderer and streaming parser. It joins the preceding run when another block starts or the turn ends, retaining the run’s id. Selection across that paragraph becomes available at completion. Code, tables, images and HTML-like content need Paseo's existing renderers. Preserve whole containers when splitting around an image would lose formatting or list context. Reference definitions retain their original block scope; grouping text for selection must not change which references resolve.
|
||||
|
||||
Selection stops at preserved blocks, blockquotes and run boundaries required by the 32,000-character render cap. Consecutive paragraphs inside a quote share its text segment; nested quotes and embedded blocks introduce further library segments. Web keeps its existing renderer and selection behavior.
|
||||
|
||||
## Flavor
|
||||
|
||||
Use `github` to retain strikethrough and gain task checkboxes. `commonmark` allows selection across quotes but loses strikethrough and task-list parsing. GitHub quotes require separate native views, so blockquotes are run boundaries.
|
||||
|
||||
Checkbox toggling is disabled because changes would not persist. Admonitions add styling to `[!NOTE]`-style quotes without hiding their content. Ordinary quotes retain the existing blockquote styling.
|
||||
|
||||
## Compatibility constraints
|
||||
|
||||
- The 1.1.1 native parser drops entity nodes. Decode text and destination entities before rendering, retaining literal entities inside code. Whitespace entities must remain text instead of creating new Markdown blocks. Its autolinks also disagree with Paseo about bare domains and trailing punctuation, so preserve Paseo’s recognized destinations as explicit links.
|
||||
- Keep dollar-delimited text literal with `latexMath: false`; dollar amounts must not become math.
|
||||
- Spoilers have no public parser switch in 1.1.1. Escape prose pipes so `||text||` stays visible; code and link destinations retain their pipes.
|
||||
- `lineHeight` is a minimum, so font metrics can increase it. Compare spacing on devices.
|
||||
- Strikethrough color is iOS-only. Android uses the surrounding text color.
|
||||
- Strong text uses bold instead of the theme's weight 500. The public API accepts only bold or normal, and the app has no bundled medium-weight font family to substitute.
|
||||
- Native h1/h2 lose their 1px bottom borders because the library has no heading-border API. H6 keeps uppercase plain text but loses its 0.5 letter spacing; code and link destinations keep their source casing.
|
||||
- Android heading spans override inline code's font, size and color. iOS retains the code style; its system-monospace alias needs mapping at the library boundary. Web headings are unchanged.
|
||||
- Published 1.1.1 cannot embed Paseo's custom React link pills or hover tooltips in its text view. Links retain Paseo's press handling.
|
||||
- Adding a completed block replaces the run’s attributed text. The library does not retain selections made while that run changes. The growing block never goes through native prose preparation.
|
||||
|
||||
Keep the package's math, highlighting, tree-sitter and video engines disabled. Paseo owns those renderers; duplicate native engines add binary size without serving this surface. Rebuild the dev client after changing engine configuration.
|
||||
|
||||
Test Android selection-handle dragging against the compiled client. A JavaScript test cannot prove that the inverted timeline's scroll protection prevents movement during selection.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
Contribute Android heading-span precedence fixes and heading border style props upstream so inline code and h1/h2 borders retain their previous appearance without breaking selection across headings.
|
||||
Generated
+22
-86
@@ -16265,15 +16265,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/camelize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001777",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
|
||||
@@ -17088,15 +17079,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-color-keywords": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
|
||||
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/css-in-js-utils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
||||
@@ -17122,17 +17104,6 @@
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-to-react-native": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
|
||||
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelize": "^1.0.0",
|
||||
"css-color-keywords": "^1.0.0",
|
||||
"postcss-value-parser": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||
@@ -30248,6 +30219,7 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -30259,6 +30231,7 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
@@ -30905,32 +30878,6 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-enriched-markdown": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-enriched-markdown/-/react-native-enriched-markdown-1.1.1.tgz",
|
||||
"integrity": "sha512-JvbRIVy75MAoGCv8V6sSscu11/Ian588wfIgH+j64XFpf6gy5sNNkMyG087MH88jNCf3p7fTjgEAGxZMhEqL4A==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"katex": ">=0.16.0",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"katex": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-fit-image": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",
|
||||
"integrity": "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==",
|
||||
"license": "Beerware",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.5.10"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-gesture-handler": {
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
|
||||
@@ -30956,22 +30903,6 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-markdown-display": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",
|
||||
"integrity": "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-to-react-native": "^3.0.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-native-fit-image": "^1.5.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.2.0",
|
||||
"react-native": ">=0.50.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-nitro-modules": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.5.tgz",
|
||||
@@ -31037,18 +30968,6 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-uitextview": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-uitextview/-/react-native-uitextview-2.2.0.tgz",
|
||||
"integrity": "sha512-Vbv3cTAuyfkYrfsR2YKFsOd9OYgfys+IX5yvvYY7Wd6TrOd6FSGrX93xtQHjeLXV1ds6fDJcFJsuLi3S4Ypr8A==",
|
||||
"workspaces": [
|
||||
"example"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-unistyles": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.2.4.tgz",
|
||||
@@ -36497,7 +36416,9 @@
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^1.50.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mdast-util-from-markdown": "2.0.3",
|
||||
"mermaid": "^11.16.0",
|
||||
"micromark-util-decode-string": "2.0.1",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
@@ -36507,16 +36428,14 @@
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-enriched-markdown": "1.1.1",
|
||||
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
"react-native-uitextview": "^2.2.0",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
@@ -37396,6 +37315,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/app/node_modules/react-native-enriched-markdown": {
|
||||
"version": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"resolved": "https://registry.npmjs.org/react-native-enriched-markdown/-/react-native-enriched-markdown-1.2.0-nightly-20261014-766fc0e67.tgz",
|
||||
"integrity": "sha512-PyYAAVJAyjlcqHXgSnzxok13MDmyxs/nt46hHIOBdCc0prfaL2Ef6nYkZu7fFCeaETOJXd/xQr4n2/79I+gvdA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"katex": ">=0.16.0",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"katex": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/react-native-keyboard-controller": {
|
||||
"version": "1.21.12",
|
||||
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.12.tgz",
|
||||
|
||||
@@ -73,9 +73,14 @@ const ASSISTANT_MARKDOWN = [
|
||||
].join("\n");
|
||||
|
||||
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
|
||||
`${CROSS_STRUCTURE_MARKDOWN}\n\n`,
|
||||
"",
|
||||
"www.example.com stays",
|
||||
"[www.example.com](http://www.example.com) stays",
|
||||
)
|
||||
.replace(
|
||||
"`https://example.com/generated` stays",
|
||||
"[`https://example.com/generated`](https://example.com/generated) stays",
|
||||
)
|
||||
.replace(`${CROSS_STRUCTURE_MARKDOWN}\n\n`, "")
|
||||
.replace(
|
||||
"<https://autolink.example.com>",
|
||||
"[https://autolink.example.com](https://autolink.example.com)",
|
||||
@@ -98,8 +103,7 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of one assistant message. A message renders one row per Markdown block, so
|
||||
* every locator and every text walk below covers the group, not a single block.
|
||||
* Locate assistant rows by source-message identity.
|
||||
*/
|
||||
function assistantMessageBlocks(page: Page): Locator {
|
||||
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
|
||||
@@ -173,7 +177,7 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
|
||||
const blocks = assistantMessageBlocks(page);
|
||||
const target = blocks.getByText(text);
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
const frame = await blocks.locator('[data-paseo-markdown-tag="img"]').boundingBox();
|
||||
const frame = await blocks.locator("img").boundingBox();
|
||||
const end = await target.evaluate((element, selectedText) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
@@ -192,7 +196,8 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
|
||||
if (!frame) {
|
||||
throw new Error("Expected a rendered image");
|
||||
}
|
||||
await page.mouse.move(end.left + 5, frame.y + frame.height + 4);
|
||||
await page.evaluate(() => window.getSelection()?.removeAllRanges());
|
||||
await page.mouse.move(end.left + 1, end.y - 14);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(end.x, end.y, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
@@ -210,18 +215,12 @@ async function selectAssistantListItemFromMarker(
|
||||
): Promise<void> {
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
const item = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
|
||||
.locator(listTag)
|
||||
.filter({ hasText: itemText })
|
||||
.locator(':scope > [data-paseo-markdown-tag="li"]')
|
||||
.locator(":scope > li")
|
||||
.filter({ hasText: itemText });
|
||||
|
||||
await item.evaluate((element, selectedEndText) => {
|
||||
const marker = element.querySelector(':scope > [data-paseo-markdown-list-marker="true"]');
|
||||
const markerText = marker?.firstChild;
|
||||
if (!(markerText instanceof Text)) {
|
||||
throw new Error("Expected rendered list marker text");
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let endNode: Text | null = null;
|
||||
let endOffset = -1;
|
||||
@@ -239,7 +238,7 @@ async function selectAssistantListItemFromMarker(
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(markerText, 0);
|
||||
range.setStart(element, 0);
|
||||
range.setEnd(endNode, endOffset);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
@@ -254,7 +253,7 @@ async function doubleClickAssistantMarkdownText(
|
||||
): Promise<void> {
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
||||
.locator(tag)
|
||||
.filter({ hasText: text })
|
||||
.dblclick({ position: { x: 5, y: 5 } });
|
||||
}
|
||||
@@ -276,39 +275,23 @@ async function selectAssistantTextRange(
|
||||
found = blockWalker.nextNode();
|
||||
}
|
||||
}
|
||||
let cursor = 0;
|
||||
const walker = { nextNode: () => nodes[cursor++] ?? null };
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let endNode: Node | null = null;
|
||||
let endOffset = -1;
|
||||
let textNode = walker.nextNode();
|
||||
while (textNode) {
|
||||
if (!startNode) {
|
||||
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
|
||||
if (offset >= 0) {
|
||||
startNode = textNode;
|
||||
startOffset = offset;
|
||||
}
|
||||
}
|
||||
if (startNode) {
|
||||
const offset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
|
||||
if (offset >= 0) {
|
||||
endNode = textNode;
|
||||
endOffset = offset + selectedRange.endText.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
textNode = walker.nextNode();
|
||||
}
|
||||
if (!startNode || !endNode) {
|
||||
const text = nodes.map((node) => node.data).join("");
|
||||
const start = text.indexOf(selectedRange.startText);
|
||||
const end = text.indexOf(selectedRange.endText, start);
|
||||
if (start < 0 || end < 0)
|
||||
throw new Error(
|
||||
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`,
|
||||
);
|
||||
}
|
||||
const boundary = (offset: number, isEnd = false): [Text, number] => {
|
||||
for (const node of nodes) {
|
||||
if (offset < node.length || (isEnd && offset === node.length)) return [node, offset];
|
||||
offset -= node.length;
|
||||
}
|
||||
throw new Error("Selection extends beyond message");
|
||||
};
|
||||
const range = document.createRange();
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(endNode, endOffset);
|
||||
range.setStart(...boundary(start));
|
||||
range.setEnd(...boundary(end + selectedRange.endText.length, true));
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
@@ -428,16 +411,12 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
["em", "emphasized prose"],
|
||||
["s", "struck prose"],
|
||||
]) {
|
||||
const formattedProse = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
||||
.filter({ hasText: text });
|
||||
const formattedProse = assistantMessage.locator(tag).filter({ hasText: text });
|
||||
await expect(formattedProse).toHaveCSS("font-family", "serif");
|
||||
await expect(formattedProse).not.toHaveAttribute("data-pmono");
|
||||
}
|
||||
const inlineCode = assistantMessage
|
||||
.locator('[data-paseo-markdown-tag="code"]')
|
||||
.filter({ hasText: "apply_patch" });
|
||||
await expect(inlineCode).toHaveAttribute("data-pmono", "");
|
||||
const inlineCode = assistantMessage.locator("code").filter({ hasText: "apply_patch" });
|
||||
await expect(inlineCode).toHaveCSS("font-family", /monospace|Mono/);
|
||||
|
||||
await selectStructuredClipboardFixture(page);
|
||||
await copySelection(page);
|
||||
@@ -466,7 +445,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
|
||||
);
|
||||
expect(clipboard.html).toContain("<code>https://example.com/generated</code>");
|
||||
expect(clipboard.html).not.toContain(
|
||||
expect(clipboard.html).toContain(
|
||||
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>',
|
||||
);
|
||||
expect(clipboard.html).toContain('<code class="language-typescript">');
|
||||
@@ -512,7 +491,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
" - Switch Claude models and confirm each model retains its own thinking choice.",
|
||||
" - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.",
|
||||
" - Confirm Opus 5 exposes the Fast toggle.",
|
||||
" - Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
|
||||
" - Smoke with an older saved [Fabel/model](Fabel/model) alias and with [daemon/client](daemon/client) version drift.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(headingAndListClipboard.html).toMatch(
|
||||
|
||||
@@ -33,8 +33,8 @@ const STREAMED_RESPONSE = [
|
||||
const RESPONSE =
|
||||
'İ😀hello **world**; hello *world*.\n\n[hello & world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
|
||||
|
||||
// One message far taller than the viewport, so its first and last paragraph rows are
|
||||
// never in the virtualizer's window at the same time.
|
||||
// One message taller than the viewport, exercising navigation between distant
|
||||
// matches inside the same rendered row.
|
||||
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
|
||||
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
|
||||
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
closeDiagramFullscreen,
|
||||
closeDiagramFullscreenFromOutside,
|
||||
expectCompletedDiagram,
|
||||
expectFullscreenDiagram,
|
||||
expectFullscreenDiagramClosed,
|
||||
openDiagramFullscreen,
|
||||
requestDiagram,
|
||||
waitForDiagramTurnToComplete,
|
||||
zoomAndPanFullscreenDiagram,
|
||||
} from "../support/helpers/diagram";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const MERMAID_DIAGRAM = [
|
||||
"```mermaid",
|
||||
"flowchart LR",
|
||||
" Start --> Middle",
|
||||
" Middle --> Review",
|
||||
" Review --> Ship",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-fullscreen-",
|
||||
title: "Fullscreen Mermaid",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: MERMAID_DIAGRAM,
|
||||
mockStreamingAssistantIntervalMs: 10,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await test.step("Open the conversation and wait for the rendered diagram", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
await waitForDiagramTurnToComplete(agent);
|
||||
await expectCompletedDiagram(page, ["Start", "Ship"]);
|
||||
});
|
||||
|
||||
await test.step("The toolbar opens the diagram fullscreen", async () => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
|
||||
});
|
||||
|
||||
await test.step("The fullscreen diagram zooms with the wheel and pans by dragging", async () => {
|
||||
await zoomAndPanFullscreenDiagram(page);
|
||||
});
|
||||
|
||||
await test.step("The close action returns to the conversation", async () => {
|
||||
await closeDiagramFullscreen(page);
|
||||
await expectFullscreenDiagramClosed(page);
|
||||
await expectCompletedDiagram(page, ["Start", "Ship"]);
|
||||
});
|
||||
|
||||
await test.step("Escape closes the fullscreen diagram", async () => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Ship"]);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectFullscreenDiagramClosed(page);
|
||||
});
|
||||
|
||||
await test.step("Clicking outside the diagram closes the fullscreen viewer", async () => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Ship"]);
|
||||
await closeDiagramFullscreenFromOutside(page);
|
||||
await expectFullscreenDiagramClosed(page);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectCompletedDiagram,
|
||||
expectDiagramRemainsRenderedWhileStreaming,
|
||||
expectDiagramWithLabels,
|
||||
reloadConversation,
|
||||
requestDiagram,
|
||||
waitForDiagramTurnToComplete,
|
||||
} from "../support/helpers/diagram";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const STREAMED_MERMAID = [
|
||||
"```mermaid",
|
||||
"flowchart LR",
|
||||
" Start --> Middle",
|
||||
' Middle --> Done["<i>Done</i>"]',
|
||||
" Middle --> Review",
|
||||
" Review --> Verify",
|
||||
" Verify --> Ship",
|
||||
" Ship --> Package",
|
||||
" Package --> Sign",
|
||||
" Sign --> Upload",
|
||||
" Upload --> Publish",
|
||||
" Publish --> Deploy",
|
||||
" Deploy --> Observe",
|
||||
" Observe --> Validate",
|
||||
" Validate --> Announce",
|
||||
" Announce --> Document",
|
||||
" Document --> Archive",
|
||||
" Archive --> Release",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
let diagramTurnCompleted!: Promise<void>;
|
||||
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-streaming-",
|
||||
title: "Streaming Mermaid",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: STREAMED_MERMAID,
|
||||
mockStreamingAssistantIntervalMs: 150,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await test.step("Open the conversation and request a diagram", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
diagramTurnCompleted = waitForDiagramTurnToComplete(agent);
|
||||
});
|
||||
|
||||
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
|
||||
await expectDiagramWithLabels(page, ["Start", "Middle"]);
|
||||
await expectDiagramRemainsRenderedWhileStreaming(page, diagramTurnCompleted);
|
||||
});
|
||||
|
||||
await test.step("The completed diagram shows the final streamed content", async () => {
|
||||
await diagramTurnCompleted;
|
||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||
});
|
||||
|
||||
await test.step("The completed diagram remains rendered after reload", async () => {
|
||||
await reloadConversation(page);
|
||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -2,14 +2,7 @@ import { expect, test } from "../support/fixtures";
|
||||
import { waitForPermissionPrompt } from "../support/helpers/permissions";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// The plan card has to pass its own markdown parser. Drop that prop and
|
||||
// react-native-markdown-display silently supplies one with `typographer: true`
|
||||
// (node_modules/react-native-markdown-display/src/index.js:139-141), which
|
||||
// turns (c) into ©, curls straight quotes, and rewrites --- as an em dash.
|
||||
// No unit test can catch that, because the defect lives in the JSX.
|
||||
//
|
||||
// Nothing here is provider-specific: the mock provider drives it, and the
|
||||
// behavior is the same for every agent.
|
||||
// Literal shell commands and copyright markers must survive Markdown rendering.
|
||||
test.describe("Plan card markdown", () => {
|
||||
test("renders plan text verbatim", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
@@ -25,6 +18,7 @@ test.describe("Plan card markdown", () => {
|
||||
await waitForPermissionPrompt(page, 120_000);
|
||||
|
||||
const planCard = page.getByTestId("permission-plan-card");
|
||||
await expect(planCard.locator(".enrm-text")).toBeVisible();
|
||||
await expect(planCard).toContainText("(c)");
|
||||
await expect(planCard).toContainText('--name="my repo"');
|
||||
await expect(planCard).toContainText("---buzz");
|
||||
@@ -35,3 +29,38 @@ 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 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`.";
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "shared-markdown-",
|
||||
title: "Shared Markdown",
|
||||
featureValues: { mockAssistantResponse: source },
|
||||
initialPrompt: "Show Markdown",
|
||||
});
|
||||
try {
|
||||
await writeFile(`${session.cwd}/README.md`, source);
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message).toHaveCount(1);
|
||||
await expect(message.locator(".enrm-text > p").first()).toContainText("First paragraph.");
|
||||
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 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 testInfo.attach("shared-renderer", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,13 +52,11 @@ export async function expectAcpMarkdown(page: Page, count: number): Promise<void
|
||||
await expect(messages).toHaveCount(count);
|
||||
for (let index = 0; index < count; index++) {
|
||||
const message = messages.nth(index);
|
||||
await expect(message).toHaveText("•Current temperature: 25°C. Read Paseo docs.");
|
||||
await expect(message).toHaveText("Current temperature: 25°C. Read Paseo docs.");
|
||||
await expect(
|
||||
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
|
||||
).toHaveAttribute("href", "https://example.com/docs");
|
||||
await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveText(
|
||||
"Current temperature",
|
||||
);
|
||||
await expect(message.locator("strong")).toHaveText("Current temperature");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { MockAgentWorkspace } from "./mock-agent";
|
||||
|
||||
const DIAGRAM_NAME = "Diagram";
|
||||
|
||||
function renderedDiagram(page: Page) {
|
||||
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
||||
const svg = diagram.locator("iframe").contentFrame().locator("#diagram svg");
|
||||
return { diagram, svg };
|
||||
}
|
||||
|
||||
export async function requestDiagram(agent: MockAgentWorkspace): Promise<void> {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Render the requested Mermaid diagram.");
|
||||
}
|
||||
|
||||
export async function expectDiagramWithLabels(
|
||||
page: Page,
|
||||
labels: readonly string[],
|
||||
): Promise<void> {
|
||||
const { diagram, svg } = renderedDiagram(page);
|
||||
await expect(diagram).toBeVisible({ timeout: 30_000 });
|
||||
await expect(svg).toBeVisible({ timeout: 30_000 });
|
||||
for (const label of labels) {
|
||||
await expect(svg).toContainText(label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectDiagramRemainsRenderedWhileStreaming(
|
||||
page: Page,
|
||||
completion: Promise<void>,
|
||||
): Promise<void> {
|
||||
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
||||
let turnCompleted = false;
|
||||
const completed = completion.then(() => {
|
||||
turnCompleted = true;
|
||||
return true;
|
||||
});
|
||||
for (;;) {
|
||||
expect(await diagram.isVisible()).toBe(true);
|
||||
if (turnCompleted) return completion;
|
||||
const box = await diagram.boundingBox();
|
||||
if (turnCompleted) return completion;
|
||||
expect(box, "the inline diagram should have measurable layout").not.toBeNull();
|
||||
expect(
|
||||
box?.height,
|
||||
"the inline diagram should remain tall enough to read",
|
||||
).toBeGreaterThanOrEqual(56);
|
||||
const didComplete = await Promise.race([completed, page.waitForTimeout(16).then(() => false)]);
|
||||
if (didComplete) return completion;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDiagramTurnToComplete(agent: MockAgentWorkspace): Promise<void> {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
|
||||
export async function expectCompletedDiagram(page: Page, labels: readonly string[]): Promise<void> {
|
||||
await expectDiagramWithLabels(page, labels);
|
||||
}
|
||||
|
||||
export async function reloadConversation(page: Page): Promise<void> {
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
}
|
||||
|
||||
const FULLSCREEN_VIEWPORT = "mermaid-fullscreen-viewport";
|
||||
|
||||
function diagramSvg(page: Page, viewportTestId: string) {
|
||||
return page
|
||||
.getByTestId(`${viewportTestId}-canvas`)
|
||||
.last()
|
||||
.locator("iframe")
|
||||
.contentFrame()
|
||||
.locator("#diagram svg");
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewport toolbar only reveals itself on hover, so the diagram is hovered first. The
|
||||
* pointer is parked elsewhere beforehand so the hover transition fires even when a previous
|
||||
* step left the cursor inside the viewport.
|
||||
*/
|
||||
export async function openDiagramFullscreen(page: Page): Promise<void> {
|
||||
await page.mouse.move(0, 0);
|
||||
await page.getByTestId("mermaid-viewport-canvas").last().hover();
|
||||
await page.getByTestId("mermaid-fullscreen").last().click();
|
||||
}
|
||||
|
||||
export async function closeDiagramFullscreen(page: Page): Promise<void> {
|
||||
await page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`).hover();
|
||||
await page.getByTestId("mermaid-fullscreen-close").click();
|
||||
}
|
||||
|
||||
export async function zoomAndPanFullscreenDiagram(page: Page): Promise<void> {
|
||||
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
|
||||
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
|
||||
const initial = await svg.boundingBox();
|
||||
expect(initial, "the fullscreen diagram should have measurable layout").not.toBeNull();
|
||||
|
||||
await page.mouse.move(initial!.x + initial!.width / 2, initial!.y + initial!.height / 2);
|
||||
await page.mouse.wheel(0, -100);
|
||||
|
||||
const zoomed = await svg.boundingBox();
|
||||
expect(zoomed, "the zoomed diagram should have measurable layout").not.toBeNull();
|
||||
expect(zoomed!.width).toBeGreaterThan(initial!.width * 1.1);
|
||||
|
||||
const viewport = await canvas.boundingBox();
|
||||
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
|
||||
await page.mouse.move(viewport!.x + viewport!.width / 2, viewport!.y + viewport!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
viewport!.x + viewport!.width / 2 - 120,
|
||||
viewport!.y + viewport!.height / 2,
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
const panned = await svg.boundingBox();
|
||||
expect(panned, "the panned diagram should have measurable layout").not.toBeNull();
|
||||
expect(Math.abs(panned!.x - zoomed!.x)).toBeGreaterThan(100);
|
||||
}
|
||||
|
||||
export async function closeDiagramFullscreenFromOutside(page: Page): Promise<void> {
|
||||
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
|
||||
const viewport = await canvas.boundingBox();
|
||||
const svg = await diagramSvg(page, FULLSCREEN_VIEWPORT).boundingBox();
|
||||
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
|
||||
expect(svg, "the fullscreen diagram should have measurable layout").not.toBeNull();
|
||||
|
||||
const x = viewport!.x + viewport!.width / 2;
|
||||
const y =
|
||||
svg!.y + svg!.height < viewport!.y + viewport!.height - 8
|
||||
? viewport!.y + viewport!.height - 4
|
||||
: viewport!.y + 4;
|
||||
await page.mouse.click(x, y);
|
||||
}
|
||||
|
||||
export async function expectFullscreenDiagram(
|
||||
page: Page,
|
||||
labels: readonly string[],
|
||||
): Promise<void> {
|
||||
const viewport = page.getByTestId(FULLSCREEN_VIEWPORT);
|
||||
await expect(viewport).toBeVisible({ timeout: 30_000 });
|
||||
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
|
||||
await expect(svg).toBeVisible({ timeout: 30_000 });
|
||||
for (const label of labels) {
|
||||
await expect(svg).toContainText(label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
|
||||
}
|
||||
@@ -45,11 +45,8 @@ export async function requestStreamingMarkdown(agent: StreamingMarkdownAgent): P
|
||||
|
||||
export async function expectUnfinishedBold(page: Page): Promise<void> {
|
||||
const message = page.getByTestId("assistant-message").last();
|
||||
const bold = message.locator('[data-paseo-markdown-tag="strong"]');
|
||||
await expect(bold).toContainText("Bold");
|
||||
await expect(message.locator(".enrm-text")).toContainText("Bold");
|
||||
await expect(message).not.toContainText("stays bold");
|
||||
await expect(bold).toHaveCSS("font-weight", "500");
|
||||
await expect(message).not.toContainText("*");
|
||||
}
|
||||
|
||||
export async function expectUnfinishedLink(
|
||||
@@ -61,7 +58,6 @@ export async function expectUnfinishedLink(
|
||||
await agent.stream.showThrough("**Bold text stays bold** and [Paseo docs");
|
||||
await expect(message).toContainText("Paseo docs");
|
||||
await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0);
|
||||
await expect(message).not.toContainText("[");
|
||||
await expect(message).not.toContainText("https:");
|
||||
await captureMarkdown(page, testInfo, "unfinished-link");
|
||||
}
|
||||
@@ -95,8 +91,5 @@ async function expectCompletedMarkdown(page: Page): Promise<void> {
|
||||
await expect(
|
||||
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
|
||||
).toHaveAttribute("href", "https://example.com/documentation");
|
||||
await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveCSS(
|
||||
"font-weight",
|
||||
"500",
|
||||
);
|
||||
await expect(message.locator("strong")).toHaveCSS("font-weight", "700");
|
||||
}
|
||||
|
||||
@@ -104,7 +104,9 @@
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^1.50.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mdast-util-from-markdown": "2.0.3",
|
||||
"mermaid": "^11.16.0",
|
||||
"micromark-util-decode-string": "2.0.1",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
@@ -114,16 +116,14 @@
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-enriched-markdown": "1.1.1",
|
||||
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
"react-native-uitextview": "^2.2.0",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
@@ -167,8 +167,28 @@
|
||||
},
|
||||
"enriched-markdown": {
|
||||
"enableMath": false,
|
||||
"enableCodeHighlight": false,
|
||||
"codeHighlightLanguages": [],
|
||||
"enableCodeHighlight": true,
|
||||
"codeHighlightLanguages": [
|
||||
"json",
|
||||
"html",
|
||||
"css",
|
||||
"markdown",
|
||||
"yaml",
|
||||
"go",
|
||||
"java",
|
||||
"javascript",
|
||||
"python",
|
||||
"c",
|
||||
"rust",
|
||||
"bash",
|
||||
"typescript",
|
||||
"tsx",
|
||||
"cpp",
|
||||
"swift",
|
||||
"php",
|
||||
"ruby",
|
||||
"c-sharp"
|
||||
],
|
||||
"enableVideo": false
|
||||
},
|
||||
"reanimated": {
|
||||
|
||||
@@ -65,3 +65,14 @@ it("matches whitespace across text nodes without inspecting hidden link destinat
|
||||
"world\nline",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps semantic Markdown paragraphs separate while finding inline matches", () => {
|
||||
const row = content(
|
||||
"<p>İ😀hello <strong>world</strong>; hello <em>world</em>.</p><p>another paragraph</p>",
|
||||
);
|
||||
expect(findRenderedMatches(row, "hello world").map((range) => range.toString())).toEqual([
|
||||
"hello world",
|
||||
"hello world",
|
||||
]);
|
||||
expect(findRenderedMatches(row, ".another")).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const BLOCKS = ["p", "pre", "li", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6"]
|
||||
.map((tag) => `[data-paseo-markdown-tag="${tag}"]`)
|
||||
.flatMap((tag) => [tag, `[data-paseo-markdown-tag="${tag}"]`])
|
||||
.join(",");
|
||||
const IGNORED =
|
||||
'[data-paseo-markdown-ignore="true"], [aria-hidden="true"], button, [role="button"], svg, script, style';
|
||||
|
||||
/** The rows of one message, in reading order. Each is one Markdown block. */
|
||||
/** Rendered rows carrying a source message identity, in reading order. */
|
||||
export function findMessageRows(root: HTMLElement | null, messageId: string): HTMLElement[] {
|
||||
if (!root) return [];
|
||||
return Array.from(
|
||||
|
||||
@@ -98,3 +98,23 @@ function highlightedRow(): string | undefined {
|
||||
);
|
||||
return row?.dataset.historyRowId;
|
||||
}
|
||||
|
||||
it("waits for an asynchronous renderer before counting its matches", async () => {
|
||||
const root = transcript("message-1", ["needle"]);
|
||||
const content = root.querySelector('[data-message-text="true"]')!;
|
||||
content.setAttribute("aria-busy", "true");
|
||||
let settled = false;
|
||||
const reveal = viewport(root, {})
|
||||
.reveal("message-1", "needle", 0, new AbortController().signal)
|
||||
.then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
expect(settled).toBe(false);
|
||||
content.textContent = "needle and another needle";
|
||||
content.removeAttribute("aria-busy");
|
||||
expect(await reveal).toEqual({ occurrence: 0, count: 2 });
|
||||
});
|
||||
|
||||
@@ -21,8 +21,7 @@ export function createFindViewport({
|
||||
clear() {
|
||||
CSS.highlights.delete(highlightName);
|
||||
},
|
||||
// A message renders as one row per Markdown block, so an occurrence can sit in
|
||||
// any of them and the chosen one decides where the viewport lands.
|
||||
// The source message owns navigation; the rendered occurrence owns its position.
|
||||
reveal(messageId, query, occurrence, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let frame = 0;
|
||||
@@ -61,6 +60,10 @@ export function createFindViewport({
|
||||
frame = requestAnimationFrame(poll);
|
||||
return;
|
||||
}
|
||||
if (rows.some((row) => row.querySelector('[aria-busy="true"]'))) {
|
||||
frame = requestAnimationFrame(poll);
|
||||
return;
|
||||
}
|
||||
const ranges = findMessageMatches(getRoot(), messageId, query);
|
||||
const index =
|
||||
occurrence < 0 ? ranges.length - 1 : Math.min(occurrence, ranges.length - 1);
|
||||
|
||||
@@ -49,17 +49,6 @@ function assistantMessage(id: string): Extract<StreamItem, { kind: "assistant_me
|
||||
};
|
||||
}
|
||||
|
||||
function assistantBlock(
|
||||
id: string,
|
||||
blockIndex: number,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
...assistantMessage(id),
|
||||
blockGroupId: "response",
|
||||
blockIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function timingFor(assistantId: string): Map<string, TurnTiming> {
|
||||
return new Map([
|
||||
[
|
||||
@@ -109,30 +98,4 @@ describe("turn footer spacing", () => {
|
||||
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["web", false],
|
||||
["web", true],
|
||||
["native", false],
|
||||
["native", true],
|
||||
] as const)(
|
||||
"compacts both edges of the final split paragraph on %s when active=%s",
|
||||
(platform, isTurnActive) => {
|
||||
const strategy = strategyFor(platform);
|
||||
const first = assistantBlock("first", 0);
|
||||
const last = assistantBlock("last", 1);
|
||||
const layout = layoutStream({
|
||||
strategy,
|
||||
isTurnActive,
|
||||
history: strategy.orderTail([userMessage("user"), first]),
|
||||
liveHead: strategy.orderHead([last]),
|
||||
timingByAssistantId: timingFor(last.id),
|
||||
});
|
||||
const firstLayout = layout.history.find((item) => item.item.id === first.id);
|
||||
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
|
||||
|
||||
expect(firstLayout?.assistantSpacing).toBe("compactBottom");
|
||||
expect(lastLayout?.assistantSpacing).toBe("compactBoth");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "use
|
||||
function assistantMessage(
|
||||
id: string,
|
||||
seed: number,
|
||||
block?: { groupId: string; index: number },
|
||||
_block?: { groupId: string; index: number },
|
||||
turnId?: string,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
@@ -33,7 +33,6 @@ function assistantMessage(
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
|
||||
...(turnId ? { turnId } : {}),
|
||||
};
|
||||
}
|
||||
@@ -318,7 +317,7 @@ describe("layoutStream", () => {
|
||||
expect(assistantRow.frameOrder).toBe("content-then-footer");
|
||||
});
|
||||
|
||||
it("compacts assistant block spacing across the history and live-head boundary", () => {
|
||||
it("keeps separate messages spaced across history and the live head", () => {
|
||||
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
|
||||
const layout = layoutFor({
|
||||
@@ -328,8 +327,8 @@ describe("layoutStream", () => {
|
||||
timingIds: [historyBlock.id, headBlock.id],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
|
||||
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBoth");
|
||||
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("default");
|
||||
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBottom");
|
||||
});
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
|
||||
import { continuesResponse, continuesTurn, isResponseBoundary } from "./turn-membership";
|
||||
|
||||
@@ -311,10 +311,8 @@ function layoutSegmentItem(
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
const assistantSpacing = getAssistantBlockSpacing({
|
||||
const assistantSpacing = getAssistantMessageSpacing({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT } from "@/components/assistant-message-render-limit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate";
|
||||
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import type { InstalledPlugin } from "@/plugins/types";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
@@ -11,9 +8,11 @@ import {
|
||||
type ToolCallItem,
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
|
||||
const runtime = {
|
||||
paseo: {},
|
||||
@@ -124,326 +123,22 @@ function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[]
|
||||
return [...result.tail, ...result.head];
|
||||
}
|
||||
|
||||
describe("native prose runs", () => {
|
||||
it("keeps an oversized history block on the existing block renderer", () => {
|
||||
const text = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
|
||||
const source = hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]);
|
||||
const result = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toHaveLength(1);
|
||||
expect(result.tail[0]).toMatchObject({ text, markdownKind: "block" });
|
||||
expect(result.tail[0]).not.toHaveProperty("markdownProseBlocks", expect.anything());
|
||||
});
|
||||
|
||||
it("keeps a normal history paragraph as selectable prose", () => {
|
||||
const text = "A normal paragraph.";
|
||||
const source = hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]);
|
||||
const result = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toHaveLength(1);
|
||||
expect(result.tail[0]).toMatchObject({
|
||||
text,
|
||||
markdownKind: "prose",
|
||||
markdownProseBlocks: [text],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains completed runs and their source arrays while the tail grows", () => {
|
||||
const present = createStreamPresentation({ markdown: "prose-runs" });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
state = applyStreamEvent({
|
||||
...state,
|
||||
event: assistant("First.\n\nSecond.\n\n```js\n1\n```\n\nTail."),
|
||||
timestamp: new Date(1000),
|
||||
});
|
||||
const before = rows(present({ ...presentationOptions, ...state, transform: undefined }));
|
||||
state = applyStreamEvent({ ...state, event: assistant(" More."), timestamp: new Date(1000) });
|
||||
const after = rows(present({ ...presentationOptions, ...state, transform: undefined }));
|
||||
expect(after[0]).toBe(before[0]);
|
||||
expect(after[1]).toBe(before[1]);
|
||||
expect(after[2]).not.toBe(before[2]);
|
||||
});
|
||||
it("does not duplicate retained prose around split images on append", () => {
|
||||
const present = createStreamPresentation({ markdown: "prose-runs" });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
let combined = "";
|
||||
for (const text of ["First.\n\nBefore  after.", "\n\nNext.", " More."]) {
|
||||
combined += text;
|
||||
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
|
||||
const live = rows(
|
||||
present({ ...presentationOptions, ...state, isTurnActive: false, transform: undefined }),
|
||||
);
|
||||
const history = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
describe("whole-message Markdown presentation", () => {
|
||||
it("keeps paragraphs, fences and quotes in one progressively updated row", () => {
|
||||
const present = createStreamPresentation();
|
||||
const text = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
||||
for (const length of [10, 30, text.length]) {
|
||||
const source = hydrateStreamState([
|
||||
{ event: assistant(text.slice(0, length)), timestamp: new Date(1000) },
|
||||
]);
|
||||
const result = present({
|
||||
...presentationOptions,
|
||||
tail: hydrateStreamState([{ event: assistant(combined), timestamp: new Date(1000) }]),
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(live).toEqual(history.tail);
|
||||
}
|
||||
});
|
||||
it.each([
|
||||
["short paragraphs", "A short paragraph.\n\n".repeat(3).slice(0, 50)],
|
||||
[
|
||||
"one growing paragraph",
|
||||
"Use `foo` with https://example.com/path and `bar`. ".repeat(2).slice(0, 50),
|
||||
],
|
||||
])("keeps 600 appends near blocks mode: %s", { retry: 2 }, (_name, chunk) => {
|
||||
function measure(markdown: "blocks" | "prose-runs") {
|
||||
const present = createStreamPresentation({ markdown });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 600; i++) {
|
||||
state = applyStreamEvent({
|
||||
...state,
|
||||
event: assistant(chunk),
|
||||
timestamp: new Date(1000),
|
||||
});
|
||||
present({ ...presentationOptions, ...state, transform: undefined });
|
||||
}
|
||||
return performance.now() - start;
|
||||
}
|
||||
measure("blocks");
|
||||
const blocks = measure("blocks");
|
||||
const prose = measure("prose-runs");
|
||||
expect(prose).toBeLessThan(Math.max(300, blocks * 3));
|
||||
expect(prose).toBeLessThan(1500);
|
||||
});
|
||||
it.each([
|
||||
"<!-- keep this -->\nText.",
|
||||
"<?pi hello?>",
|
||||
"<!DOCTYPE html>",
|
||||
"Text <!-- inline --> after",
|
||||
])("preserves HTML constructs: %s", (text) => {
|
||||
const source = hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]);
|
||||
const result = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toMatchObject([{ text, markdownKind: "block" }]);
|
||||
});
|
||||
it.each(["blocks", "prose-runs"] as const)(
|
||||
"projects a completed streamed reference definition like fresh history in %s",
|
||||
(markdown) => {
|
||||
const present = createStreamPresentation({ markdown });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
for (const text of ["![shot][pic]\n\n[pic]:", " shots/app.png"]) {
|
||||
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
|
||||
present({ ...presentationOptions, ...state, transform: undefined });
|
||||
}
|
||||
const live = present({
|
||||
...presentationOptions,
|
||||
...state,
|
||||
isTurnActive: false,
|
||||
transform: undefined,
|
||||
});
|
||||
const history = createStreamPresentation({ markdown })({
|
||||
...presentationOptions,
|
||||
tail: hydrateStreamState([
|
||||
{ event: assistant("![shot][pic]\n\n[pic]: shots/app.png"), timestamp: new Date(1000) },
|
||||
]),
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect([...live.tail, ...live.head]).toEqual(history.tail);
|
||||
},
|
||||
);
|
||||
it("keeps file URL images in Paseo's renderer", () => {
|
||||
const source = hydrateStreamState([
|
||||
{
|
||||
event: assistant("Before  after."),
|
||||
timestamp: new Date(1000),
|
||||
},
|
||||
]);
|
||||
const result = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toMatchObject([
|
||||
{ text: "Before", markdownKind: "prose" },
|
||||
{ text: "", markdownKind: "block" },
|
||||
{ text: "after.", markdownKind: "prose" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts another run before combined paragraphs exceed the render cap", () => {
|
||||
const first = "a".repeat(20_000);
|
||||
const second = "b".repeat(20_000);
|
||||
const present = createStreamPresentation({ markdown: "prose-runs" });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
for (const text of [first, "\n\n" + second.slice(0, 10_000), second.slice(10_000)]) {
|
||||
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
|
||||
present({ ...presentationOptions, ...state, transform: undefined });
|
||||
}
|
||||
const live = present({
|
||||
...presentationOptions,
|
||||
...state,
|
||||
isTurnActive: false,
|
||||
transform: undefined,
|
||||
});
|
||||
expect([...live.tail, ...live.head]).toMatchObject([
|
||||
{ id: "message-1:run:0", text: first },
|
||||
{ id: "message-1:run:1", text: second },
|
||||
]);
|
||||
const source = hydrateStreamState([
|
||||
{ event: assistant(`${first}\n\n${second}`), timestamp: new Date(1000) },
|
||||
]);
|
||||
expect(
|
||||
present({ ...presentationOptions, tail: source, head: [], transform: undefined }).tail,
|
||||
).toMatchObject([{ text: first }, { text: second }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"![Screenshot][pic]\n\n[pic]: shots/app.png",
|
||||
"**before  after**",
|
||||
"# heading  after",
|
||||
"[before  after](https://example.com)",
|
||||
"~~before  after~~",
|
||||
"- parent\n - before  after\n - next",
|
||||
])("keeps image context in Paseo's block renderer: %s", (text) => {
|
||||
const source = hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]);
|
||||
const result = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toMatchObject([{ text, markdownKind: "block" }]);
|
||||
expect(result.tail).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("splits inline workspace images out of prose without mistaking code for images", () => {
|
||||
const source = hydrateStreamState([
|
||||
{
|
||||
event: assistant("Before ``  after."),
|
||||
timestamp: new Date(1000),
|
||||
},
|
||||
]);
|
||||
const result = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toMatchObject([
|
||||
{ text: "Before ``", markdownKind: "prose" },
|
||||
{ text: "", markdownKind: "block" },
|
||||
{ text: "after.", markdownKind: "prose" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ middle: "```ts\nconst a = 1;\n```", kind: "block" },
|
||||
{ middle: "> Quoted paragraph.", kind: "quote" },
|
||||
{ middle: "", kind: "block" },
|
||||
])("keeps $kind between prose in a separate row", ({ middle, kind }) => {
|
||||
const source = hydrateStreamState([
|
||||
{ event: assistant(`First.\n\n${middle}\n\nLast.`), timestamp: new Date(1000) },
|
||||
]);
|
||||
const result = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toMatchObject([
|
||||
{ id: `${source[0]!.id}:run:0`, text: "First.", markdownKind: "prose" },
|
||||
{ id: `${source[0]!.id}:block:1`, text: middle, markdownKind: kind },
|
||||
{ id: `${source[0]!.id}:run:2`, text: "Last.", markdownKind: "prose" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["> Quoted text.", "- List item", "### Heading", "Plain paragraph."])(
|
||||
"classifies the final block at turn completion: %s",
|
||||
(text) => {
|
||||
const present = createStreamPresentation({ markdown: "prose-runs" });
|
||||
const head = hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]);
|
||||
expect(
|
||||
present({ ...presentationOptions, tail: [], head, transform: undefined }).head,
|
||||
).toMatchObject([{ markdownKind: "block" }]);
|
||||
const completed = present({
|
||||
...presentationOptions,
|
||||
isTurnActive: false,
|
||||
tail: [],
|
||||
head,
|
||||
head: source,
|
||||
transform: undefined,
|
||||
});
|
||||
const history = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: head,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(rows(completed)).toEqual(history.tail);
|
||||
},
|
||||
);
|
||||
it("keeps the growing block separate and merges completed blocks into a stable run", () => {
|
||||
const present = createStreamPresentation({ markdown: "prose-runs" });
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
function send(text: string) {
|
||||
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
|
||||
return present({ ...presentationOptions, ...state, transform: undefined });
|
||||
expect(rows(result)).toEqual(source);
|
||||
}
|
||||
expect(send("First.").head).toMatchObject([{ id: "message-1:block:0", markdownKind: "block" }]);
|
||||
const second = send("\n\nSecond.");
|
||||
expect(second.tail).toMatchObject([
|
||||
{ id: "message-1:run:0", text: "First.", markdownKind: "prose" },
|
||||
]);
|
||||
expect(second.head).toMatchObject([
|
||||
{ id: "message-1:block:1", text: "Second.", markdownKind: "block" },
|
||||
]);
|
||||
const growing = send(" More.");
|
||||
expect(growing.tail[0]).toBe(second.tail[0]);
|
||||
const third = send("\n\nThird.");
|
||||
expect(third.tail).toMatchObject([{ id: "message-1:run:0", text: "First.\n\nSecond. More." }]);
|
||||
const completed = present({
|
||||
...presentationOptions,
|
||||
...state,
|
||||
isTurnActive: false,
|
||||
transform: undefined,
|
||||
});
|
||||
const history = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: [...state.tail, ...state.head],
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(rows(completed)).toEqual(history.tail);
|
||||
expect(rows(completed)).toMatchObject([
|
||||
{ id: "message-1:run:0", text: "First.\n\nSecond. More.\n\nThird." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps consecutive paragraphs in one selectable row", () => {
|
||||
const source = hydrateStreamState([
|
||||
{ event: assistant("First paragraph.\n\nSecond paragraph."), timestamp: new Date(1000) },
|
||||
]);
|
||||
const result = createStreamPresentation({ markdown: "prose-runs" })({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toMatchObject([
|
||||
{
|
||||
id: `${source[0]!.id}:run:0`,
|
||||
text: "First paragraph.\n\nSecond paragraph.",
|
||||
markdownKind: "prose",
|
||||
blockGroupId: source[0]!.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -522,26 +217,17 @@ describe("stream presentation through installed plugins", () => {
|
||||
{ text: "Header\n\nBody", phase: "streaming" },
|
||||
]);
|
||||
expect(rows(harness.render(transform))).toHaveLength(1);
|
||||
expect(rows(harness.render()).map((item) => item.kind)).toEqual([
|
||||
"assistant_message",
|
||||
"assistant_message",
|
||||
]);
|
||||
expect(rows(harness.render()).map((item) => item.kind)).toEqual(["assistant_message"]);
|
||||
});
|
||||
|
||||
it("keeps completed native blocks stable while the last block grows and finishes", () => {
|
||||
it("retains the complete message when streaming finishes", () => {
|
||||
const harness = streamHarness();
|
||||
const first = harness.send(assistant("Intro\n\n"));
|
||||
const growing = harness.send(assistant("\n\nAfter\n"));
|
||||
const last = harness.send(assistant("- item"));
|
||||
harness.send(assistant("Intro\n\n"));
|
||||
const last = harness.send(assistant("\n\nAfter\n- item"));
|
||||
const completed = harness.send({ type: "turn_completed", provider: "claude" });
|
||||
expect(first.tail).toMatchObject([{ text: "Intro", blockIndex: 0 }]);
|
||||
expect(first.head).toMatchObject([{ text: "", blockIndex: 1 }]);
|
||||
expect(growing.tail[0]).toBe(first.tail[0]);
|
||||
expect(growing.tail[1]).toBe(first.head[0]);
|
||||
expect(last.tail).toBe(growing.tail);
|
||||
expect(last.head).toMatchObject([{ text: "After\n- item", blockIndex: 2 }]);
|
||||
expect(completed.tail).toEqual(rows(last));
|
||||
expect(completed.tail[2]).toBe(last.head[0]);
|
||||
expect(last.tail).toEqual([]);
|
||||
expect(last.head).toMatchObject([{ text: "Intro\n\n\n\nAfter\n- item" }]);
|
||||
expect(completed.tail).toEqual(last.head);
|
||||
expect(completed.head).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -549,8 +235,8 @@ describe("stream presentation through installed plugins", () => {
|
||||
const harness = streamHarness();
|
||||
harness.send(assistant("Intro\n\n```ts\nconst a = 1;"));
|
||||
const result = harness.send(assistant("\n\nconst b = 2;"));
|
||||
expect(result.tail).toMatchObject([{ text: "Intro" }]);
|
||||
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
||||
expect(result.tail).toEqual([]);
|
||||
expect(result.head).toMatchObject([{ text: "Intro\n\n```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
||||
});
|
||||
|
||||
// Codex streams a line break and the next line's indent as their own chunks.
|
||||
@@ -568,14 +254,13 @@ describe("stream presentation through installed plugins", () => {
|
||||
// which is what lets find, scroll-to-message and history reveal address it by id.
|
||||
// A link reference definition stays with the paragraph that uses it, so the split
|
||||
// never leaves an empty row behind and the reference still resolves.
|
||||
it("splits an assistant message into the same blocks through history and through the live head", () => {
|
||||
it("preserves source identity through history and the live head", () => {
|
||||
const source = hydrateStreamState([
|
||||
{
|
||||
event: assistant("[Link][docs]\n\n[docs]: https://example.com\n\nClosing paragraph."),
|
||||
timestamp: new Date(1000),
|
||||
},
|
||||
]);
|
||||
const messageId = source[0]!.id;
|
||||
const present = (placement: "tail" | "head") =>
|
||||
rows(
|
||||
createStreamPresentation()({
|
||||
@@ -585,20 +270,7 @@ describe("stream presentation through installed plugins", () => {
|
||||
transform: undefined,
|
||||
}),
|
||||
);
|
||||
expect(present("tail")).toMatchObject([
|
||||
{
|
||||
id: `${messageId}:block:0`,
|
||||
blockGroupId: messageId,
|
||||
blockIndex: 0,
|
||||
text: "[Link][docs]\n\n[docs]: https://example.com",
|
||||
},
|
||||
{
|
||||
id: `${messageId}:block:1`,
|
||||
blockGroupId: messageId,
|
||||
blockIndex: 1,
|
||||
text: "Closing paragraph.",
|
||||
},
|
||||
]);
|
||||
expect(present("tail")).toEqual(source);
|
||||
const blocks = (items: StreamItem[]) =>
|
||||
items.map((item) => [item.id, item.kind === "assistant_message" ? item.text : null]);
|
||||
expect(blocks(present("head"))).toEqual(blocks(present("tail")));
|
||||
@@ -606,7 +278,7 @@ describe("stream presentation through installed plugins", () => {
|
||||
|
||||
// Rows are addressed by id from outside presentation, so the same text has to land on
|
||||
// the same ids whether the reader watched it arrive or opened the chat afterwards.
|
||||
it("gives a streamed message the block ids its hydrated text would have had", () => {
|
||||
it("gives a streamed message the same id as its hydrated text", () => {
|
||||
const text = "Intro paragraph.\n\n# Heading\n\n- first\n- second\n\nClosing paragraph.";
|
||||
const harness = streamHarness();
|
||||
for (let end = 8; end < text.length; end += 8) {
|
||||
@@ -628,7 +300,7 @@ describe("stream presentation through installed plugins", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("repoints a block at the canonical source that replaced the streamed one", () => {
|
||||
it("uses the canonical source that replaced the streamed message", () => {
|
||||
const harness = streamHarness();
|
||||
harness.send(assistant("Streamed answer"));
|
||||
const streamed = rows(harness.render());
|
||||
@@ -662,15 +334,8 @@ describe("stream presentation through installed plugins", () => {
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toMatchObject([
|
||||
{
|
||||
id: `${messageId}:block:0`,
|
||||
blockGroupId: messageId,
|
||||
blockIndex: 0,
|
||||
text: "Only one paragraph.",
|
||||
},
|
||||
]);
|
||||
expect(result.tail[0]!.id).not.toBe(messageId);
|
||||
expect(result.tail).toEqual(source);
|
||||
expect(result.tail[0]!.id).toBe(messageId);
|
||||
});
|
||||
|
||||
it("continues to stream inline reasoning with a stable plugin row", () => {
|
||||
@@ -887,13 +552,7 @@ describe("timeline presentation", () => {
|
||||
text: "<spoken-input>Example</spoken-input><instruction>Explain this XML.</instruction>",
|
||||
},
|
||||
];
|
||||
expect(projectTimelineItems(items)).toEqual(
|
||||
items.map((item) =>
|
||||
item.kind === "assistant_message"
|
||||
? { ...item, id: `${item.id}:block:0`, blockGroupId: item.id, blockIndex: 0 }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
expect(projectTimelineItems(items)).toEqual(items);
|
||||
expect(projectTimelineItems(items)[0]).toBe(items[0]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import type { TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import {
|
||||
prepareToolCallHistory,
|
||||
projectToolCallDetailLevel,
|
||||
type PreparedToolCallHistory,
|
||||
type ToolCallDetailLevel,
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
|
||||
import { groupMarkdownProse } from "@/components/markdown-prose";
|
||||
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
||||
|
||||
interface PresentationInput {
|
||||
tail: StreamItem[];
|
||||
@@ -19,47 +16,14 @@ interface PresentationInput {
|
||||
isTurnActive: boolean;
|
||||
}
|
||||
|
||||
function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
|
||||
return previous.length === next.length && previous.every((item, index) => item === next[index])
|
||||
? previous
|
||||
: next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The source message a display row belongs to. Every assistant message is split into
|
||||
* Markdown blocks, so an assistant row id never equals its message id; anything that
|
||||
* addresses a message — find, scroll-to-message, history reveal — asks for this. A
|
||||
* plugin row a timeline transformer projected answers for the item it replaced, whose
|
||||
* id is what the chat outline and find hold.
|
||||
*/
|
||||
/** Find and scrolling address a plugin row by the source it replaces. */
|
||||
export function getStreamItemMessageId(item: StreamItem): string {
|
||||
if (item.kind === "assistant_message") return item.blockGroupId ?? item.id;
|
||||
if (item.kind === "plugin") return item.sourceId ?? item.id;
|
||||
return item.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A block is reusable only when it still stands for the same source state. Text and
|
||||
* turn are what the reader sees; cursor and timestamp are what the timeline reads back
|
||||
* through the row — a reused block carrying a pre-canonical cursor reports a stale
|
||||
* reading position and weakens fork-boundary resolution.
|
||||
*/
|
||||
function isReusableBlock(block: AssistantMessageItem, source: AssistantMessageItem, id: string) {
|
||||
return (
|
||||
block.id === id &&
|
||||
block.turnId === source.turnId &&
|
||||
block.timestamp.getTime() === source.timestamp.getTime() &&
|
||||
block.timelineCursor?.epoch === source.timelineCursor?.epoch &&
|
||||
block.timelineCursor?.seq === source.timelineCursor?.seq
|
||||
);
|
||||
}
|
||||
|
||||
/** Source messages reach plugins before any Markdown splitting or Overview grouping. */
|
||||
export function createStreamPresentation({
|
||||
markdown = "blocks",
|
||||
}: { markdown?: "blocks" | "prose-runs" } = {}) {
|
||||
const runsBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
|
||||
const growingRunsBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
|
||||
/** Source messages reach plugins before display projection. */
|
||||
export function createStreamPresentation() {
|
||||
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
|
||||
|
||||
function presentUserMessage(item: UserMessageItem): UserMessageItem {
|
||||
@@ -77,95 +41,20 @@ export function createStreamPresentation({
|
||||
return projected;
|
||||
}
|
||||
|
||||
const blocksBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
|
||||
let liveSources = new Map<string, AssistantMessageItem>();
|
||||
let historySource: StreamItem[] | undefined;
|
||||
let historyTransform: TimelineItemTransform | undefined;
|
||||
let historyRows: StreamItem[] = [];
|
||||
let displayTail: StreamItem[] = [];
|
||||
let displayHistory: StreamItem[] | undefined;
|
||||
let promotedRows: StreamItem[] = [];
|
||||
let preparedTail: StreamItem[] | undefined;
|
||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||
|
||||
/**
|
||||
* One display row per Markdown block. Each block renders on its own, so a construct
|
||||
* that needs context from another block does not resolve: `splitMarkdownBlocks` keeps
|
||||
* link reference definitions with the block that uses them, but a reference pointing
|
||||
* at a definition several blocks away renders as literal text. Streamed messages
|
||||
* always behaved this way; history now matches them.
|
||||
*/
|
||||
function nativeBlocks(item: StreamItem): StreamItem[] {
|
||||
if (item.kind === "user_message") return [presentUserMessage(item)];
|
||||
if (item.kind !== "assistant_message") return [item];
|
||||
const cached = blocksBySource.get(item);
|
||||
if (cached) return cached;
|
||||
const previousSource = liveSources.get(item.id);
|
||||
const previous = previousSource && blocksBySource.get(previousSource);
|
||||
const isAppend = previousSource && item.text.startsWith(previousSource.text);
|
||||
let prefix: AssistantMessageItem[] = [];
|
||||
let growingText = item.text;
|
||||
if (isAppend && previous) {
|
||||
prefix = previous.slice(0, -1);
|
||||
growingText =
|
||||
previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length);
|
||||
}
|
||||
let parsed = splitMarkdownBlocks(growingText);
|
||||
// A definition completed in the last block can now belong to the preceding block.
|
||||
if (prefix.length && /^\s{0,3}\[[^\]]+\]:/.test(parsed[0] ?? growingText)) {
|
||||
prefix = [];
|
||||
parsed = splitMarkdownBlocks(item.text);
|
||||
}
|
||||
const textBlocks = parsed.length + prefix.length > 0 ? parsed : [""];
|
||||
const blocks = [...prefix];
|
||||
for (const [offset, text] of textBlocks.entries()) {
|
||||
const index = prefix.length + offset;
|
||||
let blockText = text;
|
||||
if (offset === textBlocks.length - 1) {
|
||||
// The split drops trailing blank lines, including a line break followed only
|
||||
// by the next line's indent. Keep them so the next append continues that line.
|
||||
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
|
||||
blockText += trailingBlankLines;
|
||||
}
|
||||
const existing = previous?.[index];
|
||||
const id = `${item.id}:block:${index}`;
|
||||
if (existing?.text === blockText && isReusableBlock(existing, item, id)) {
|
||||
blocks.push(existing);
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
...item,
|
||||
id,
|
||||
blockGroupId: item.id,
|
||||
blockIndex: index,
|
||||
text: blockText,
|
||||
});
|
||||
}
|
||||
blocksBySource.set(item, blocks);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function presentRows(item: StreamItem, growing = false): StreamItem[] {
|
||||
const blocks = nativeBlocks(item);
|
||||
if (markdown === "blocks" || item.kind !== "assistant_message") return blocks;
|
||||
const cache = growing ? growingRunsBySource : runsBySource;
|
||||
const cached = cache.get(item);
|
||||
if (cached) return cached;
|
||||
const previousSource = liveSources.get(item.id);
|
||||
const previousBlocks = previousSource && blocksBySource.get(previousSource);
|
||||
// nativeBlocks emits only assistant blocks for an assistant source.
|
||||
const runs = groupMarkdownProse(blocks as AssistantMessageItem[], previousBlocks, growing);
|
||||
cache.set(item, runs);
|
||||
return runs;
|
||||
function presentRows(item: StreamItem): StreamItem[] {
|
||||
return [item.kind === "user_message" ? presentUserMessage(item) : item];
|
||||
}
|
||||
|
||||
return (input: PresentationInput) => {
|
||||
// Retained history is not reprojected or regrouped on each live text update.
|
||||
if (historySource !== input.tail || historyTransform !== input.transform) {
|
||||
// One rendering path: history splits the same way the live head does, and
|
||||
// `blocksBySource` keeps both the live block identities and the split cost
|
||||
// from being paid again when only the tail's array identity changed.
|
||||
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
|
||||
(item) => presentRows(item),
|
||||
);
|
||||
@@ -173,34 +62,18 @@ export function createStreamPresentation({
|
||||
historyTransform = input.transform;
|
||||
}
|
||||
const head: StreamItem[] = [];
|
||||
const promoted: StreamItem[] = [];
|
||||
const nextLiveSources = new Map<string, AssistantMessageItem>();
|
||||
for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) {
|
||||
const blocks = presentRows(item, input.isTurnActive);
|
||||
if (item.kind === "assistant_message") nextLiveSources.set(item.id, item);
|
||||
if (item.kind === "assistant_message" && blocks.length > 1) {
|
||||
promoted.push(...blocks.slice(0, -1));
|
||||
head.push(blocks[blocks.length - 1]!);
|
||||
} else {
|
||||
head.push(...blocks);
|
||||
}
|
||||
const blocks = presentRows(item);
|
||||
head.push(...blocks);
|
||||
}
|
||||
liveSources = nextLiveSources;
|
||||
const nextPromoted = retainItems(promotedRows, promoted);
|
||||
if (displayHistory !== historyRows || nextPromoted !== promotedRows) {
|
||||
displayTail = historyRows;
|
||||
if (nextPromoted.length > 0) displayTail = [...historyRows, ...nextPromoted];
|
||||
displayHistory = historyRows;
|
||||
promotedRows = nextPromoted;
|
||||
}
|
||||
if (preparedTail !== displayTail || preparedLevel !== input.level) {
|
||||
preparedHistory = prepareToolCallHistory(input.level, displayTail);
|
||||
preparedTail = displayTail;
|
||||
if (preparedTail !== historyRows || preparedLevel !== input.level) {
|
||||
preparedHistory = prepareToolCallHistory(input.level, historyRows);
|
||||
preparedTail = historyRows;
|
||||
preparedLevel = input.level;
|
||||
}
|
||||
return projectToolCallDetailLevel({
|
||||
level: input.level,
|
||||
tail: displayTail,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
|
||||
@@ -1,135 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./spacing";
|
||||
|
||||
function assistantBlock(params: {
|
||||
id: string;
|
||||
blockGroupId: string;
|
||||
blockIndex: number;
|
||||
text?: string;
|
||||
}): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id: params.id,
|
||||
blockGroupId: params.blockGroupId,
|
||||
blockIndex: params.blockIndex,
|
||||
text: params.text ?? "",
|
||||
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallBlock(id: string): Extract<StreamItem, { kind: "tool_call" }> {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
||||
payload: {
|
||||
source: "orchestrator",
|
||||
data: {
|
||||
toolCallId: id,
|
||||
toolName: "bash",
|
||||
arguments: "cmd",
|
||||
result: null,
|
||||
status: "executing",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("isSameAssistantBlockGroup", () => {
|
||||
it("returns true for two assistant blocks with the same blockGroupId", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const b = assistantBlock({ id: "b", blockGroupId: "group-1", blockIndex: 1 });
|
||||
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for blocks from different groups", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const b = assistantBlock({ id: "b", blockGroupId: "group-2", blockIndex: 0 });
|
||||
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when one item is not an assistant_message", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const tc = toolCallBlock("tc-1");
|
||||
expect(isSameAssistantBlockGroup({ item: a, other: tc })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null neighbors", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(isSameAssistantBlockGroup({ item: a, other: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantBlockSpacing", () => {
|
||||
it("returns default for non-assistant items", () => {
|
||||
const tc = toolCallBlock("tc-1");
|
||||
expect(getAssistantBlockSpacing({ item: tc, aboveItem: null, belowItem: null })).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns default when no same-group neighbors exist", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
|
||||
});
|
||||
|
||||
it("compacts the bottom edge when the turn footer follows the assistant block", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(
|
||||
getAssistantBlockSpacing({
|
||||
item: a,
|
||||
aboveItem: null,
|
||||
belowItem: null,
|
||||
hasFooterBelow: true,
|
||||
}),
|
||||
).toBe("compactBottom");
|
||||
});
|
||||
|
||||
it("returns compactTop when the item above is in the same block group", () => {
|
||||
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
||||
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: null })).toBe(
|
||||
"compactTop",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns compactBottom when the item below is in the same block group", () => {
|
||||
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 1 });
|
||||
expect(getAssistantBlockSpacing({ item, aboveItem: null, belowItem: below })).toBe(
|
||||
const message: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "m",
|
||||
text: "First\n\nSecond",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
describe("whole-message spacing", () => {
|
||||
it("compacts only the gap to its turn footer", () => {
|
||||
expect(getAssistantMessageSpacing({ item: message })).toBe("default");
|
||||
expect(getAssistantMessageSpacing({ item: message, hasFooterBelow: true })).toBe(
|
||||
"compactBottom",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns compactBoth when both neighbors are in the same block group", () => {
|
||||
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
||||
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 2 });
|
||||
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: below })).toBe(
|
||||
"compactBoth",
|
||||
);
|
||||
});
|
||||
|
||||
it("spans the history/live-head boundary: tail gets compactBottom, head gets compactTop", () => {
|
||||
const tailBlock = assistantBlock({
|
||||
id: "group-1:block:0",
|
||||
blockGroupId: "group-1",
|
||||
blockIndex: 0,
|
||||
text: "First paragraph",
|
||||
});
|
||||
const headBlock = assistantBlock({
|
||||
id: "group-1:head",
|
||||
blockGroupId: "group-1",
|
||||
blockIndex: 1,
|
||||
text: "Second paragraph",
|
||||
});
|
||||
|
||||
expect(
|
||||
getAssistantBlockSpacing({ item: tailBlock, aboveItem: null, belowItem: headBlock }),
|
||||
).toBe("compactBottom");
|
||||
expect(
|
||||
getAssistantBlockSpacing({ item: headBlock, aboveItem: tailBlock, belowItem: null }),
|
||||
).toBe("compactTop");
|
||||
it("keeps separate messages separated", () => {
|
||||
expect(getGapBetweenStreamItems(message, { ...message, id: "next" })).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,13 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
export function isSameAssistantBlockGroup(params: {
|
||||
item: StreamItem | null | undefined;
|
||||
other: StreamItem | null | undefined;
|
||||
}): boolean {
|
||||
return (
|
||||
params.item?.kind === "assistant_message" &&
|
||||
params.other?.kind === "assistant_message" &&
|
||||
params.item.blockGroupId !== undefined &&
|
||||
params.item.blockGroupId === params.other.blockGroupId
|
||||
);
|
||||
}
|
||||
|
||||
export function getAssistantBlockSpacing(params: {
|
||||
export function getAssistantMessageSpacing(params: {
|
||||
item: StreamItem;
|
||||
aboveItem: StreamItem | null | undefined;
|
||||
belowItem: StreamItem | null | undefined;
|
||||
hasFooterBelow?: boolean;
|
||||
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
|
||||
if (params.item.kind !== "assistant_message") {
|
||||
return "default";
|
||||
}
|
||||
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
|
||||
const compactBottom =
|
||||
params.hasFooterBelow ||
|
||||
isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
|
||||
if (compactTop && compactBottom) return "compactBoth";
|
||||
if (compactTop) return "compactTop";
|
||||
if (compactBottom) return "compactBottom";
|
||||
return "default";
|
||||
}): "default" | "compactBottom" {
|
||||
return params.item.kind === "assistant_message" && params.hasFooterBelow
|
||||
? "compactBottom"
|
||||
: "default";
|
||||
}
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
|
||||
@@ -62,8 +40,5 @@ export function getGapBetweenStreamItems(
|
||||
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
||||
return SPACING[1];
|
||||
}
|
||||
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
|
||||
return SPACING[3];
|
||||
}
|
||||
return SPACING[4];
|
||||
}
|
||||
|
||||
@@ -307,7 +307,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
olderHistoryProgressKey,
|
||||
scrollEnabled,
|
||||
contentMaxWidth,
|
||||
imageContext,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
const historyVirtualized = useRevisedHistoryRows(
|
||||
@@ -455,7 +454,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const row = segments.historyVirtualized[index];
|
||||
const measured = row && measuredRowHeights.current.get(row.id);
|
||||
if (measured && measured.width === contentWidth) return measured.height;
|
||||
return row ? estimateStreamItemHeight({ item: row, contentMaxWidth, imageContext }) : 120;
|
||||
return row ? estimateStreamItemHeight({ item: row, contentMaxWidth }) : 120;
|
||||
},
|
||||
observeElementOffset: observeVirtualOffset,
|
||||
rangeExtractor,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
|
||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -95,7 +94,6 @@ export interface StreamRenderInput {
|
||||
forwardListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
|
||||
contentMaxWidth: number;
|
||||
imageContext?: AssistantImageContext;
|
||||
}
|
||||
|
||||
export interface ResolveStreamRenderStrategyInput {
|
||||
|
||||
@@ -535,10 +535,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
|
||||
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
|
||||
const isTurnActive = effectiveTurnPresentation.isActive;
|
||||
const presentStream = useMemo(
|
||||
() => createStreamPresentation({ markdown: isWeb ? "blocks" : "prose-runs" }),
|
||||
[],
|
||||
);
|
||||
const presentStream = useMemo(() => createStreamPresentation(), []);
|
||||
const presentation = useMemo(
|
||||
() =>
|
||||
presentStream({
|
||||
@@ -729,8 +726,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
<ChatFindExpansion messageId={getStreamItemMessageId(item)}>
|
||||
{(renderFullContent) => (
|
||||
<AssistantMessage
|
||||
markdownKind={item.markdownKind}
|
||||
markdownProseBlocks={item.markdownProseBlocks}
|
||||
renderFullContent={renderFullContent}
|
||||
occurrenceKey={createAssistantImageOccurrenceKey({ agentId, itemId: item.id })}
|
||||
message={item.text}
|
||||
@@ -1137,7 +1132,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
contentMaxWidth,
|
||||
imageContext: { serverId: resolvedServerId, workspaceRoot },
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
<ChatOutlineRail
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
clearAssistantImageMetadataCache,
|
||||
setAssistantImageMetadata,
|
||||
} from "@/utils/assistant-image-metadata";
|
||||
setAssistantMessageHeight,
|
||||
clearAssistantMessageHeightEstimateCache,
|
||||
} from "@/utils/assistant-message-height-estimate";
|
||||
import {
|
||||
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||
@@ -161,56 +161,18 @@ describe("estimateStreamItemHeight", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses local image dimensions only from the row's server and workspace", () => {
|
||||
clearAssistantImageMetadataCache();
|
||||
const imageContext = { serverId: "server-one", workspaceRoot: "/workspace/one" };
|
||||
setAssistantImageMetadata(
|
||||
{ source: "portrait.png", ...imageContext },
|
||||
{ width: 800, height: 1600 },
|
||||
);
|
||||
it("uses the renderer's whole-message measurement at the measured width", () => {
|
||||
clearAssistantMessageHeightEstimateCache();
|
||||
const item: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "local-image",
|
||||
text: "",
|
||||
id: "measured",
|
||||
text: "First paragraph\n\n",
|
||||
timestamp: createTimestamp(2),
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight({ item, contentMaxWidth: 608, imageContext })).toBe(1264);
|
||||
expect(
|
||||
estimateStreamItemHeight({
|
||||
item,
|
||||
contentMaxWidth: 608,
|
||||
imageContext: { ...imageContext, workspaceRoot: "/workspace/two" },
|
||||
}),
|
||||
).toBe(464);
|
||||
expect(
|
||||
estimateStreamItemHeight({
|
||||
item,
|
||||
contentMaxWidth: 608,
|
||||
imageContext: { ...imageContext, serverId: "server-two" },
|
||||
}),
|
||||
).toBe(464);
|
||||
});
|
||||
|
||||
it("uses cached assistant image metadata when available", () => {
|
||||
clearAssistantImageMetadataCache();
|
||||
setAssistantImageMetadata(
|
||||
{
|
||||
source: "https://example.com/tall.png",
|
||||
},
|
||||
{ width: 800, height: 1600 },
|
||||
);
|
||||
|
||||
const item: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "a-image",
|
||||
text: "Look at this\n\n",
|
||||
timestamp: createTimestamp(2),
|
||||
};
|
||||
|
||||
expect(
|
||||
estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH }),
|
||||
).toBeGreaterThan(220);
|
||||
setAssistantMessageHeight({ markdown: item.text, width: 608, height: 640 });
|
||||
expect(estimateStreamItemHeight({ item, contentMaxWidth: 608 })).toBe(640);
|
||||
expect(estimateStreamItemHeight({ item, contentMaxWidth: 320 })).toBe(220);
|
||||
clearAssistantMessageHeightEstimateCache();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate";
|
||||
import {
|
||||
@@ -47,13 +46,11 @@ export interface WebVirtualizedHistoryWindow {
|
||||
interface StreamItemHeightEstimateInput {
|
||||
item: StreamItem;
|
||||
contentMaxWidth: number;
|
||||
imageContext?: AssistantImageContext;
|
||||
}
|
||||
|
||||
export function estimateStreamItemHeight({
|
||||
item,
|
||||
contentMaxWidth,
|
||||
imageContext,
|
||||
}: StreamItemHeightEstimateInput): number {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
@@ -63,7 +60,6 @@ export function estimateStreamItemHeight({
|
||||
estimateAssistantMessageHeightFromCache({
|
||||
markdown: item.text,
|
||||
contentMaxWidth,
|
||||
imageContext,
|
||||
}) ?? 220
|
||||
);
|
||||
case "tool_call":
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
export {
|
||||
AssistantInlineCodePathLink,
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
} from "./link";
|
||||
export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context";
|
||||
export {
|
||||
classifyAssistantFileLink,
|
||||
normalizeInlinePathTarget,
|
||||
@@ -11,6 +5,7 @@ export {
|
||||
} from "./parse";
|
||||
export {
|
||||
AssistantFileLinkResolverProvider,
|
||||
useAssistantFileLinkEnvironment,
|
||||
type AssistantFileLinkResolverProviderProps,
|
||||
} from "./provider";
|
||||
export type { AssistantFileLinkSource } from "./resolver";
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { TextProps } from "react-native";
|
||||
|
||||
// Carries a link's press handler down to the leaf text spans that render its
|
||||
// label. On iOS an assistant link is a nested UITextView span, and
|
||||
// react-native-uitextview only attaches onPress to the *string* children it
|
||||
// converts into RNUITextViewChild nodes (src/Text.tsx) — element children (the
|
||||
// MarkdownInheritedText spans markdown produces for link text) pass through
|
||||
// untouched, so an onPress placed on the wrapping span never reaches a tappable
|
||||
// native node. Threading the handler through context lets each leaf span hand
|
||||
// onPress to its own string children, where the native tap recognizer can find
|
||||
// it. Provided only on iOS (Android/web links tap fine via their own paths).
|
||||
export interface AssistantLinkPress {
|
||||
onPress: () => void;
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
|
||||
const AssistantLinkPressContext = createContext<AssistantLinkPress | null>(null);
|
||||
|
||||
export const AssistantLinkPressProvider = AssistantLinkPressContext.Provider;
|
||||
|
||||
export function useAssistantLinkPress(): AssistantLinkPress | null {
|
||||
return useContext(AssistantLinkPressContext);
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import { useMemo, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
||||
import { Platform, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { MarkdownLinkText } from "@/components/markdown/link-text";
|
||||
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
|
||||
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||
import type { AssistantFileLinkSource } from "./resolver";
|
||||
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||
import { useFileLink } from "./use-file-link";
|
||||
|
||||
interface AssistantMarkdownLinkProps {
|
||||
source: AssistantFileLinkSource;
|
||||
style: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_CODE_LINK_DATASET = {
|
||||
...CODE_SURFACE_DATASET,
|
||||
...markdownCopyDataSet.code,
|
||||
} as const;
|
||||
|
||||
export function AssistantMarkdownLink({
|
||||
source,
|
||||
style,
|
||||
monoSurface,
|
||||
children,
|
||||
}: AssistantMarkdownLinkProps) {
|
||||
const { target, onHoverIn, onPress } = useFileLink(source);
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
const workspaceRoot = configRef.current.workspaceRoot;
|
||||
const tooltipPath = useMemo(
|
||||
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
|
||||
[target, workspaceRoot],
|
||||
);
|
||||
const linkPress = useMemo<AssistantLinkPress>(
|
||||
() => ({ onPress, accessibilityRole: "link" }),
|
||||
[onPress],
|
||||
);
|
||||
const unwrapForMarkdownCopy = source.sourceType === "inline-code" || source.markup === "linkify";
|
||||
|
||||
if (isNative) {
|
||||
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
|
||||
// inside the paragraph's native UITextView, and a plain <Text> nested there
|
||||
// is not hoisted into a UITextViewChild, so its text is silently dropped
|
||||
// (the link disappears). The span composes correctly and stays selectable.
|
||||
//
|
||||
// Tap-to-open: react-native-uitextview only wires onPress onto the *string*
|
||||
// children it turns into RNUITextViewChild nodes — the element children that
|
||||
// markdown emits for link text pass through untouched, so an onPress placed
|
||||
// here never reaches a tappable native node. We thread it down through
|
||||
// AssistantLinkPressProvider so each leaf text span re-attaches it to its
|
||||
// own string children, where the native tap recognizer can find it. iOS
|
||||
// only: Android forwards onPress through nested <Text> already, and web uses
|
||||
// the <a> path below.
|
||||
const span = (
|
||||
<MarkdownTextSpan
|
||||
accessibilityRole="link"
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
return (
|
||||
<FileLinkHoverTooltip filePath={tooltipPath}>
|
||||
{Platform.OS === "ios" ? (
|
||||
<AssistantLinkPressProvider value={linkPress}>{span}</AssistantLinkPressProvider>
|
||||
) : (
|
||||
span
|
||||
)}
|
||||
</FileLinkHoverTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const anchor = (
|
||||
<a
|
||||
{...(unwrapForMarkdownCopy ? { "data-paseo-markdown-unwrap": "true" } : {})}
|
||||
href={source.href}
|
||||
title={source.title}
|
||||
onClickCapture={preventAnchorNavigation}
|
||||
onAuxClickCapture={preventAnchorNavigation}
|
||||
style={LINK_ANCHOR_STYLE}
|
||||
>
|
||||
<MarkdownLinkText
|
||||
dataSet={monoSurface ? MARKDOWN_CODE_LINK_DATASET : undefined}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
onHoverIn={onHoverIn}
|
||||
>
|
||||
{children}
|
||||
</MarkdownLinkText>
|
||||
</a>
|
||||
);
|
||||
|
||||
return <FileLinkHoverTooltip filePath={tooltipPath}>{anchor}</FileLinkHoverTooltip>;
|
||||
}
|
||||
|
||||
interface AssistantMarkdownCodeLinkProps {
|
||||
source: AssistantFileLinkSource;
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AssistantMarkdownCodeLink({
|
||||
source,
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
children,
|
||||
}: AssistantMarkdownCodeLinkProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, codeInlineStyle, linkStyle],
|
||||
[inheritedStyles, codeInlineStyle, linkStyle],
|
||||
);
|
||||
return (
|
||||
<AssistantMarkdownLink source={source} style={style} monoSurface>
|
||||
{children}
|
||||
</AssistantMarkdownLink>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssistantInlineCodePathLinkProps {
|
||||
content: string;
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
}
|
||||
|
||||
export function AssistantInlineCodePathLink({
|
||||
content,
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
}: AssistantInlineCodePathLinkProps) {
|
||||
const source = useMemo<AssistantFileLinkSource>(
|
||||
() => ({
|
||||
href: content,
|
||||
text: content,
|
||||
sourceType: "inline-code",
|
||||
}),
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantMarkdownCodeLink
|
||||
source={source}
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={codeInlineStyle}
|
||||
linkStyle={linkStyle}
|
||||
>
|
||||
{content}
|
||||
</AssistantMarkdownCodeLink>
|
||||
);
|
||||
}
|
||||
|
||||
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
|
||||
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
|
||||
// the tooltip wrapper from breaking inline link flow.
|
||||
display: "inline-flex" as ViewStyle["display"],
|
||||
};
|
||||
|
||||
function FileLinkHoverTooltip({
|
||||
filePath,
|
||||
children,
|
||||
}: {
|
||||
filePath: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!isWeb) {
|
||||
return children;
|
||||
}
|
||||
return (
|
||||
<Tooltip delayDuration={400}>
|
||||
<TooltipTrigger asChild>
|
||||
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
|
||||
</TooltipTrigger>
|
||||
{filePath ? (
|
||||
<TooltipContent side="top" align="start" maxWidth={520}>
|
||||
<Text selectable={false} style={styles.tooltipPath}>
|
||||
{filePath}
|
||||
</Text>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const LINK_ANCHOR_STYLE: CSSProperties = {
|
||||
display: "contents",
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
function preventAnchorNavigation(event: MouseEvent<HTMLAnchorElement>): void {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
tooltipPath: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
@@ -14,6 +14,7 @@ import type { InlinePathTarget } from "./parse";
|
||||
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
|
||||
|
||||
export interface AssistantFileLinkDaemonClient {
|
||||
readFile?: import("@getpaseo/client/internal/daemon-client").DaemonClient["readFile"];
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}
|
||||
|
||||
@@ -78,10 +79,16 @@ export function AssistantFileLinkResolverProvider({
|
||||
|
||||
export function useAssistantFileLinkResolverContext(): AssistantFileLinkResolverContextValue {
|
||||
const context = useContext(AssistantFileLinkResolverContext);
|
||||
if (!context) {
|
||||
throw new Error("AssistantFileLinkResolverProvider is required for assistant file links.");
|
||||
}
|
||||
return context;
|
||||
return context ?? EMPTY_CONTEXT;
|
||||
}
|
||||
|
||||
export type { AssistantFileLinkContext };
|
||||
|
||||
const EMPTY_CONTEXT: AssistantFileLinkResolverContextValue = {
|
||||
configRef: { current: {} },
|
||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
||||
};
|
||||
|
||||
export function useAssistantFileLinkEnvironment() {
|
||||
return useAssistantFileLinkResolverContext().configRef.current;
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAssistantImageLifecycle, transitionAssistantImageLifecycle } from "./lifecycle";
|
||||
|
||||
describe("assistant image lifecycle", () => {
|
||||
it("keeps preview URL recreation in the public loading state", () => {
|
||||
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
|
||||
type: "preview_created",
|
||||
uri: "blob:first",
|
||||
aspectRatio: null,
|
||||
});
|
||||
const loaded = transitionAssistantImageLifecycle(loading, {
|
||||
type: "image_loaded",
|
||||
uri: "blob:first",
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
|
||||
const recreating = transitionAssistantImageLifecycle(loaded, {
|
||||
type: "preview_released",
|
||||
});
|
||||
|
||||
expect(recreating).toEqual({ status: "loading", uri: null, aspectRatio: null });
|
||||
});
|
||||
|
||||
it("publishes loaded only after the recreated URL loads", () => {
|
||||
const recreating = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
|
||||
type: "preview_created",
|
||||
uri: "blob:recreated",
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
|
||||
expect(recreating).toEqual({
|
||||
status: "loading",
|
||||
uri: "blob:recreated",
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
|
||||
const recreated = transitionAssistantImageLifecycle(recreating, {
|
||||
type: "image_loaded",
|
||||
uri: "blob:recreated",
|
||||
aspectRatio: 0.75,
|
||||
});
|
||||
|
||||
expect(recreated).toEqual({
|
||||
status: "loaded",
|
||||
uri: "blob:recreated",
|
||||
aspectRatio: 0.75,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reset a loaded image when the same preview is reported again", () => {
|
||||
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
|
||||
type: "preview_created",
|
||||
uri: "blob:current",
|
||||
aspectRatio: null,
|
||||
});
|
||||
const loaded = transitionAssistantImageLifecycle(loading, {
|
||||
type: "image_loaded",
|
||||
uri: "blob:current",
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
|
||||
const repeated = transitionAssistantImageLifecycle(loaded, {
|
||||
type: "preview_created",
|
||||
uri: "blob:current",
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
|
||||
expect(repeated).toBe(loaded);
|
||||
});
|
||||
|
||||
it("publishes failed for a terminal image failure on the current URI", () => {
|
||||
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
|
||||
type: "preview_created",
|
||||
uri: "blob:current",
|
||||
aspectRatio: null,
|
||||
});
|
||||
const failed = transitionAssistantImageLifecycle(loading, {
|
||||
type: "failed",
|
||||
uri: "blob:current",
|
||||
message: "Unable to load image preview.",
|
||||
});
|
||||
|
||||
expect(failed).toEqual({
|
||||
status: "failed",
|
||||
message: "Unable to load image preview.",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a stale load callback from a replaced URI", () => {
|
||||
const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
|
||||
type: "preview_created",
|
||||
uri: "blob:current",
|
||||
aspectRatio: 1.25,
|
||||
});
|
||||
|
||||
const afterStaleLoad = transitionAssistantImageLifecycle(current, {
|
||||
type: "image_loaded",
|
||||
uri: "blob:released",
|
||||
aspectRatio: 2,
|
||||
});
|
||||
|
||||
expect(afterStaleLoad).toEqual(current);
|
||||
});
|
||||
|
||||
it("ignores a stale error callback from a replaced URI", () => {
|
||||
const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
|
||||
type: "preview_created",
|
||||
uri: "blob:current",
|
||||
aspectRatio: null,
|
||||
});
|
||||
|
||||
const afterStaleError = transitionAssistantImageLifecycle(current, {
|
||||
type: "failed",
|
||||
uri: "blob:released",
|
||||
message: "Image unavailable",
|
||||
});
|
||||
|
||||
expect(afterStaleError).toEqual(current);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
export type AssistantImageLifecycle =
|
||||
| { status: "loading"; uri: string | null; aspectRatio: number | null }
|
||||
| { status: "loaded"; uri: string; aspectRatio: number }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
export type AssistantImageLifecycleEvent =
|
||||
| { type: "preview_created"; uri: string; aspectRatio: number | null }
|
||||
| { type: "preview_released" }
|
||||
| { type: "image_loaded"; uri: string; aspectRatio: number }
|
||||
| { type: "failed"; uri: string; message: string };
|
||||
|
||||
export function createAssistantImageLifecycle(): AssistantImageLifecycle {
|
||||
return { status: "loading", uri: null, aspectRatio: null };
|
||||
}
|
||||
|
||||
export function transitionAssistantImageLifecycle(
|
||||
state: AssistantImageLifecycle,
|
||||
event: AssistantImageLifecycleEvent,
|
||||
): AssistantImageLifecycle {
|
||||
if (event.type === "image_loaded") {
|
||||
if (state.status !== "loading" || state.uri !== event.uri) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
status: "loaded",
|
||||
uri: event.uri,
|
||||
aspectRatio: event.aspectRatio,
|
||||
};
|
||||
}
|
||||
if (event.type === "failed") {
|
||||
if (state.status === "failed" || state.uri !== event.uri) {
|
||||
return state;
|
||||
}
|
||||
return { status: "failed", message: event.message };
|
||||
}
|
||||
if (event.type === "preview_created") {
|
||||
if (state.status === "loaded" && state.uri === event.uri) {
|
||||
return state;
|
||||
}
|
||||
return { status: "loading", uri: event.uri, aspectRatio: event.aspectRatio };
|
||||
}
|
||||
if (event.type === "preview_released") {
|
||||
return { status: "loading", uri: null, aspectRatio: null };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type AssistantImageRenderedDimensionsReader,
|
||||
resolveAssistantImageLoadDimensions,
|
||||
} from "./load-dimensions";
|
||||
|
||||
class MemoryRenderedDimensionsReader implements AssistantImageRenderedDimensionsReader {
|
||||
constructor(private readonly dimensions: { width: number; height: number } | null) {}
|
||||
|
||||
read(): { width: number; height: number } | null {
|
||||
return this.dimensions;
|
||||
}
|
||||
}
|
||||
|
||||
describe("assistant image load dimensions", () => {
|
||||
it("prefers native source dimensions", () => {
|
||||
const dimensions = resolveAssistantImageLoadDimensions({
|
||||
source: { width: 640, height: 320 },
|
||||
target: { naturalWidth: 800, naturalHeight: 400 },
|
||||
renderedImage: null,
|
||||
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
|
||||
});
|
||||
|
||||
expect(dimensions).toEqual({ width: 640, height: 320 });
|
||||
});
|
||||
|
||||
it("uses browser event dimensions when native dimensions are absent", () => {
|
||||
const dimensions = resolveAssistantImageLoadDimensions({
|
||||
source: { width: 0, height: 0 },
|
||||
target: { naturalWidth: 800, naturalHeight: 400 },
|
||||
renderedImage: null,
|
||||
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
|
||||
});
|
||||
|
||||
expect(dimensions).toEqual({ width: 800, height: 400 });
|
||||
});
|
||||
|
||||
it("falls back to the rendered image adapter", () => {
|
||||
const renderedImage = { id: "rendered-image" };
|
||||
const dimensions = resolveAssistantImageLoadDimensions({
|
||||
target: null,
|
||||
renderedImage,
|
||||
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
|
||||
});
|
||||
|
||||
expect(dimensions).toEqual({ width: 900, height: 600 });
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
export interface AssistantImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AssistantImageDimensionCandidate {
|
||||
width?: unknown;
|
||||
height?: unknown;
|
||||
}
|
||||
|
||||
export interface AssistantImageRenderedDimensionsReader {
|
||||
read(renderedImage: unknown): AssistantImageDimensions | null;
|
||||
}
|
||||
|
||||
function readPositiveDimensions(
|
||||
candidate: AssistantImageDimensionCandidate | null | undefined,
|
||||
): AssistantImageDimensions | null {
|
||||
if (
|
||||
typeof candidate?.width !== "number" ||
|
||||
typeof candidate.height !== "number" ||
|
||||
candidate.width <= 0 ||
|
||||
candidate.height <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { width: candidate.width, height: candidate.height };
|
||||
}
|
||||
|
||||
export function resolveAssistantImageLoadDimensions(input: {
|
||||
source?: AssistantImageDimensionCandidate | null;
|
||||
target?: { naturalWidth?: unknown; naturalHeight?: unknown } | null;
|
||||
renderedImage: unknown;
|
||||
renderedDimensions: AssistantImageRenderedDimensionsReader;
|
||||
}): AssistantImageDimensions | null {
|
||||
const source = readPositiveDimensions(input.source);
|
||||
if (source) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const target = readPositiveDimensions({
|
||||
width: input.target?.naturalWidth,
|
||||
height: input.target?.naturalHeight,
|
||||
});
|
||||
return target ?? input.renderedDimensions.read(input.renderedImage);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runAssistantImageOperationWithRetry } from "./retry";
|
||||
|
||||
describe("assistant image retry", () => {
|
||||
it("recovers from transient failures while the image remains mounted", async () => {
|
||||
const waits: number[] = [];
|
||||
let attempts = 0;
|
||||
|
||||
const result = await runAssistantImageOperationWithRetry({
|
||||
operation: async () => {
|
||||
attempts += 1;
|
||||
if (attempts < 3) {
|
||||
throw new Error("transient");
|
||||
}
|
||||
return "loaded";
|
||||
},
|
||||
delaysMs: [10, 20, 30],
|
||||
wait: async (delayMs) => {
|
||||
waits.push(delayMs);
|
||||
},
|
||||
});
|
||||
|
||||
expect({ attempts, waits, result }).toEqual({
|
||||
attempts: 3,
|
||||
waits: [10, 20],
|
||||
result: "loaded",
|
||||
});
|
||||
});
|
||||
|
||||
it("stops retrying after the image is released", async () => {
|
||||
let stopped = false;
|
||||
let attempts = 0;
|
||||
|
||||
await expect(
|
||||
runAssistantImageOperationWithRetry({
|
||||
operation: async () => {
|
||||
attempts += 1;
|
||||
throw new Error("transient");
|
||||
},
|
||||
delaysMs: [10, 20],
|
||||
shouldStop: () => stopped,
|
||||
wait: async () => {
|
||||
stopped = true;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("transient");
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
export const ASSISTANT_IMAGE_RETRY_DELAYS_MS = [100, 400, 1_200] as const;
|
||||
|
||||
export async function runAssistantImageOperationWithRetry<T>(input: {
|
||||
operation: () => Promise<T>;
|
||||
delaysMs?: readonly number[];
|
||||
shouldStop?: () => boolean;
|
||||
wait?: (delayMs: number) => Promise<void>;
|
||||
}): Promise<T> {
|
||||
const delays = input.delaysMs ?? ASSISTANT_IMAGE_RETRY_DELAYS_MS;
|
||||
const shouldStop = input.shouldStop ?? (() => false);
|
||||
const wait =
|
||||
input.wait ??
|
||||
(async (delayMs: number) => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||||
});
|
||||
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
return await input.operation();
|
||||
} catch (error) {
|
||||
const delay = delays[attempt];
|
||||
if (delay === undefined || shouldStop()) {
|
||||
throw error;
|
||||
}
|
||||
await wait(delay);
|
||||
if (shouldStop()) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ImageLoadEvent } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { retainAttachmentForGarbageCollection } from "@/attachments/gc-retention";
|
||||
import {
|
||||
persistAttachmentFromBytes,
|
||||
persistAttachmentFromDataUrl,
|
||||
releaseAttachmentPreviewUrl,
|
||||
resolveAttachmentPreviewUrl,
|
||||
} from "@/attachments/service";
|
||||
import { createPreviewAttachmentId, parseImageDataUrl } from "@/attachments/utils";
|
||||
import {
|
||||
getAssistantImageMetadata,
|
||||
setAssistantImageMetadata,
|
||||
} from "@/utils/assistant-image-metadata";
|
||||
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||
import { createAssistantImageAcquisitionCache } from "./acquisition-cache";
|
||||
import {
|
||||
createAssistantImageFileAcquisition,
|
||||
type AssistantImageAcquisition,
|
||||
type AssistantImageFileAcquisitionPort,
|
||||
} from "./file-acquisition";
|
||||
import {
|
||||
createAssistantImageLifecycle,
|
||||
transitionAssistantImageLifecycle,
|
||||
type AssistantImageLifecycle,
|
||||
type AssistantImageLifecycleEvent,
|
||||
} from "./lifecycle";
|
||||
import {
|
||||
type AssistantImageRenderedDimensionsReader,
|
||||
resolveAssistantImageLoadDimensions,
|
||||
} from "./load-dimensions";
|
||||
import { runAssistantImageOperationWithRetry } from "./retry";
|
||||
|
||||
interface AssistantImageRenderBinding {
|
||||
uri: string;
|
||||
onRef: (instance: unknown) => void;
|
||||
onLoad: (event: ImageLoadEvent) => void;
|
||||
onError: () => void;
|
||||
}
|
||||
|
||||
const renderedDimensions: AssistantImageRenderedDimensionsReader = {
|
||||
read(renderedImage) {
|
||||
if (!isWeb || !(renderedImage instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
const image = renderedImage.querySelector("img");
|
||||
return image && image.naturalWidth > 0 && image.naturalHeight > 0
|
||||
? { width: image.naturalWidth, height: image.naturalHeight }
|
||||
: null;
|
||||
},
|
||||
};
|
||||
|
||||
export type AssistantImageResult =
|
||||
| {
|
||||
status: "loading";
|
||||
binding: AssistantImageRenderBinding | null;
|
||||
aspectRatio: number | null;
|
||||
}
|
||||
| {
|
||||
status: "loaded";
|
||||
binding: AssistantImageRenderBinding;
|
||||
aspectRatio: number;
|
||||
}
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
interface UseAssistantImageInput {
|
||||
source: string;
|
||||
occurrenceKey: string;
|
||||
client?: DaemonClient | null;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
type PreviewUrlState =
|
||||
| { status: "waiting" }
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; uri: string }
|
||||
| { status: "failed"; error: unknown };
|
||||
|
||||
type AttachmentAcquisitionState =
|
||||
| { status: "waiting" }
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; attachment: AttachmentMetadata }
|
||||
| { status: "failed"; error: unknown };
|
||||
|
||||
interface DataImage {
|
||||
mimeType: string;
|
||||
base64: string;
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
const attachmentAcquisitionCache = createAssistantImageAcquisitionCache<AttachmentMetadata>({
|
||||
capacity: 500,
|
||||
onRetain: (attachment) => retainAttachmentForGarbageCollection(attachment.id),
|
||||
});
|
||||
|
||||
interface CachedPreviewUrl {
|
||||
attachment: AttachmentMetadata;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
const previewUrlCache = createAssistantImageAcquisitionCache<CachedPreviewUrl>({
|
||||
capacity: 500,
|
||||
onRetain:
|
||||
({ attachment, uri }) =>
|
||||
() => {
|
||||
void releaseAttachmentPreviewUrl({ attachment, url: uri });
|
||||
},
|
||||
});
|
||||
|
||||
const LOADED_IMAGE_CACHE_CAPACITY = 500;
|
||||
const loadedImageCache = new Map<string, number>();
|
||||
|
||||
function getLoadedImageAspectRatio(uri: string): number | null {
|
||||
const aspectRatio = loadedImageCache.get(uri);
|
||||
if (aspectRatio === undefined) {
|
||||
return null;
|
||||
}
|
||||
loadedImageCache.delete(uri);
|
||||
loadedImageCache.set(uri, aspectRatio);
|
||||
return aspectRatio;
|
||||
}
|
||||
|
||||
function rememberLoadedImage(uri: string, aspectRatio: number): void {
|
||||
loadedImageCache.delete(uri);
|
||||
loadedImageCache.set(uri, aspectRatio);
|
||||
if (loadedImageCache.size <= LOADED_IMAGE_CACHE_CAPACITY) {
|
||||
return;
|
||||
}
|
||||
const leastRecentlyUsedUri = loadedImageCache.keys().next().value;
|
||||
if (leastRecentlyUsedUri !== undefined) {
|
||||
loadedImageCache.delete(leastRecentlyUsedUri);
|
||||
}
|
||||
}
|
||||
|
||||
function useAttachmentAcquisition(
|
||||
acquisition: AssistantImageAcquisition | null,
|
||||
): AttachmentAcquisitionState {
|
||||
const acquisitionKey = acquisition?.key ?? null;
|
||||
const [entry, setEntry] = useState<{
|
||||
key: string | null;
|
||||
state: AttachmentAcquisitionState;
|
||||
}>(() => {
|
||||
const cached = acquisitionKey ? attachmentAcquisitionCache.peek(acquisitionKey) : undefined;
|
||||
return {
|
||||
key: acquisitionKey,
|
||||
state: cached ? { status: "loaded", attachment: cached } : { status: "waiting" },
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let releaseCurrent: (() => void) | null = null;
|
||||
if (!acquisition || !acquisitionKey) {
|
||||
setEntry({ key: null, state: { status: "waiting" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const acquireCurrent = () => {
|
||||
releaseCurrent?.();
|
||||
const retained = attachmentAcquisitionCache.acquireRetained(
|
||||
acquisitionKey,
|
||||
acquisition.locate,
|
||||
);
|
||||
releaseCurrent = retained.release;
|
||||
return retained;
|
||||
};
|
||||
const initial = acquireCurrent();
|
||||
if (initial.value) {
|
||||
setEntry({ key: acquisitionKey, state: { status: "loaded", attachment: initial.value } });
|
||||
return () => {
|
||||
disposed = true;
|
||||
releaseCurrent?.();
|
||||
};
|
||||
}
|
||||
|
||||
setEntry({ key: acquisitionKey, state: { status: "loading" } });
|
||||
void (async () => {
|
||||
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
|
||||
try {
|
||||
const attachment = await runAssistantImageOperationWithRetry({
|
||||
operation: async () => {
|
||||
const retained = firstAttempt ?? acquireCurrent();
|
||||
firstAttempt = null;
|
||||
return await retained.promise;
|
||||
},
|
||||
shouldStop: () => disposed,
|
||||
});
|
||||
if (!disposed) {
|
||||
setEntry({ key: acquisitionKey, state: { status: "loaded", attachment } });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!disposed) {
|
||||
setEntry({ key: acquisitionKey, state: { status: "failed", error } });
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
disposed = true;
|
||||
releaseCurrent?.();
|
||||
};
|
||||
}, [acquisition, acquisitionKey]);
|
||||
|
||||
if (!acquisitionKey) {
|
||||
return { status: "waiting" };
|
||||
}
|
||||
const cached = attachmentAcquisitionCache.peek(acquisitionKey);
|
||||
if (cached) {
|
||||
return { status: "loaded", attachment: cached };
|
||||
}
|
||||
return entry.key === acquisitionKey ? entry.state : { status: "waiting" };
|
||||
}
|
||||
|
||||
function createDataImageAcquisition(input: {
|
||||
source: string;
|
||||
dataImage: DataImage | null;
|
||||
}): AssistantImageAcquisition | null {
|
||||
if (!input.dataImage) {
|
||||
return null;
|
||||
}
|
||||
const { dataImage, source } = input;
|
||||
return {
|
||||
key: dataImage.cacheKey,
|
||||
locate: async () =>
|
||||
await persistAttachmentFromDataUrl({
|
||||
id: createPreviewAttachmentId({
|
||||
mimeType: dataImage.mimeType,
|
||||
contentLength: dataImage.base64.length,
|
||||
contentKey: dataImage.cacheKey,
|
||||
}),
|
||||
dataUrl: source,
|
||||
mimeType: dataImage.mimeType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): PreviewUrlState {
|
||||
const id = attachment?.id;
|
||||
const storageType = attachment?.storageType;
|
||||
const storageKey = attachment?.storageKey;
|
||||
const mimeType = attachment?.mimeType;
|
||||
const previewKey =
|
||||
id && storageType && storageKey && mimeType
|
||||
? `${id}:${storageType}:${storageKey}:${mimeType}`
|
||||
: null;
|
||||
const [entry, setEntry] = useState<{ key: string | null; state: PreviewUrlState }>(() => {
|
||||
return {
|
||||
key: previewKey,
|
||||
state: { status: "waiting" },
|
||||
};
|
||||
});
|
||||
const getCurrentAttachment = useStableEvent(() => attachment ?? null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let disposed = false;
|
||||
let releaseCurrent: (() => void) | null = null;
|
||||
const current = getCurrentAttachment();
|
||||
|
||||
if (!current || !previewKey) {
|
||||
setEntry({ key: null, state: { status: "waiting" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const acquireCurrent = () => {
|
||||
releaseCurrent?.();
|
||||
const retained = previewUrlCache.acquireRetained(previewKey, async () => ({
|
||||
attachment: current,
|
||||
uri: await resolveAttachmentPreviewUrl(current),
|
||||
}));
|
||||
releaseCurrent = retained.release;
|
||||
return retained;
|
||||
};
|
||||
const initial = acquireCurrent();
|
||||
if (initial.value) {
|
||||
setEntry({ key: previewKey, state: { status: "loaded", uri: initial.value.uri } });
|
||||
return () => {
|
||||
disposed = true;
|
||||
releaseCurrent?.();
|
||||
};
|
||||
}
|
||||
|
||||
setEntry({ key: previewKey, state: { status: "loading" } });
|
||||
void (async () => {
|
||||
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
|
||||
try {
|
||||
const preview = await runAssistantImageOperationWithRetry({
|
||||
operation: async () => {
|
||||
const retained = firstAttempt ?? acquireCurrent();
|
||||
firstAttempt = null;
|
||||
return await retained.promise;
|
||||
},
|
||||
shouldStop: () => disposed,
|
||||
});
|
||||
if (!disposed) {
|
||||
setEntry({ key: previewKey, state: { status: "loaded", uri: preview.uri } });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!disposed) {
|
||||
setEntry({ key: previewKey, state: { status: "failed", error } });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
releaseCurrent?.();
|
||||
};
|
||||
}, [getCurrentAttachment, previewKey]);
|
||||
|
||||
if (!previewKey) {
|
||||
return { status: "waiting" };
|
||||
}
|
||||
return entry.key === previewKey ? entry.state : { status: "waiting" };
|
||||
}
|
||||
|
||||
function lifecycleReducer(
|
||||
state: AssistantImageLifecycle,
|
||||
event: AssistantImageLifecycleEvent,
|
||||
): AssistantImageLifecycle {
|
||||
return transitionAssistantImageLifecycle(state, event);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function getAcquisitionFailure(input: {
|
||||
hasResolution: boolean;
|
||||
isFileSource: boolean;
|
||||
isDataImage: boolean;
|
||||
fileAttachment: AttachmentAcquisitionState;
|
||||
dataImageAttachment: AttachmentAcquisitionState;
|
||||
preview: PreviewUrlState;
|
||||
hasDirectUri: boolean;
|
||||
fallbackMessage: string;
|
||||
}): AssistantImageResult | null {
|
||||
if (!input.hasResolution) {
|
||||
return { status: "failed", message: input.fallbackMessage };
|
||||
}
|
||||
if (input.isFileSource && input.fileAttachment.status === "failed") {
|
||||
return {
|
||||
status: "failed",
|
||||
message: errorMessage(input.fileAttachment.error, input.fallbackMessage),
|
||||
};
|
||||
}
|
||||
if (input.isDataImage && input.dataImageAttachment.status === "failed") {
|
||||
return {
|
||||
status: "failed",
|
||||
message: errorMessage(input.dataImageAttachment.error, input.fallbackMessage),
|
||||
};
|
||||
}
|
||||
if (!input.hasDirectUri && input.preview.status === "failed") {
|
||||
return {
|
||||
status: "failed",
|
||||
message: errorMessage(input.preview.error, input.fallbackMessage),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useAssistantImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
}: UseAssistantImageInput): AssistantImageResult {
|
||||
const { t } = useTranslation();
|
||||
const resolution = useMemo(
|
||||
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
||||
[source, workspaceRoot],
|
||||
);
|
||||
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
|
||||
const fileAcquisition = useMemo(() => {
|
||||
const port: AssistantImageFileAcquisitionPort | null = client
|
||||
? {
|
||||
readFile: async (cwd, path) => await client.readFile(cwd, path),
|
||||
persist: persistAttachmentFromBytes,
|
||||
}
|
||||
: null;
|
||||
return createAssistantImageFileAcquisition({
|
||||
port,
|
||||
resolution,
|
||||
serverId,
|
||||
occurrenceKey,
|
||||
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
|
||||
});
|
||||
}, [client, occurrenceKey, resolution, serverId, t]);
|
||||
const dataImageAcquisition = useMemo(
|
||||
() => createDataImageAcquisition({ source, dataImage }),
|
||||
[dataImage, source],
|
||||
);
|
||||
const fileAttachment = useAttachmentAcquisition(fileAcquisition);
|
||||
const dataImageAttachment = useAttachmentAcquisition(dataImageAcquisition);
|
||||
const filePreview = usePreviewUrl(
|
||||
fileAttachment.status === "loaded" ? fileAttachment.attachment : null,
|
||||
);
|
||||
const dataImagePreview = usePreviewUrl(
|
||||
dataImageAttachment.status === "loaded" ? dataImageAttachment.attachment : null,
|
||||
);
|
||||
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
|
||||
const preview = dataImage ? dataImagePreview : filePreview;
|
||||
const previewUri = preview.status === "loaded" ? preview.uri : null;
|
||||
const uri = directUri ?? previewUri;
|
||||
const cachedAspectRatio = useMemo(
|
||||
() => getAssistantImageMetadata({ source, workspaceRoot, serverId })?.aspectRatio ?? null,
|
||||
[serverId, source, workspaceRoot],
|
||||
);
|
||||
const [lifecycle, dispatchLifecycle] = useReducer(
|
||||
lifecycleReducer,
|
||||
uri,
|
||||
(initialUri): AssistantImageLifecycle => {
|
||||
if (initialUri) {
|
||||
const aspectRatio = getLoadedImageAspectRatio(initialUri);
|
||||
if (aspectRatio !== null) {
|
||||
return { status: "loaded", uri: initialUri, aspectRatio };
|
||||
}
|
||||
}
|
||||
return createAssistantImageLifecycle();
|
||||
},
|
||||
);
|
||||
const dispatch = useCallback((event: AssistantImageLifecycleEvent) => {
|
||||
if (event.type === "image_loaded") {
|
||||
rememberLoadedImage(event.uri, event.aspectRatio);
|
||||
} else if (event.type === "failed" && event.uri) {
|
||||
loadedImageCache.delete(event.uri);
|
||||
}
|
||||
dispatchLifecycle(event);
|
||||
}, []);
|
||||
const renderedImageRef = useRef<unknown>(null);
|
||||
const handleImageRef = useCallback((instance: unknown) => {
|
||||
renderedImageRef.current = instance;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uri) {
|
||||
dispatch({ type: "preview_released" });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "preview_created",
|
||||
uri,
|
||||
aspectRatio: cachedAspectRatio,
|
||||
});
|
||||
}, [cachedAspectRatio, dispatch, uri]);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
if (uri) {
|
||||
dispatch({
|
||||
type: "failed",
|
||||
uri,
|
||||
message: t("message.attachments.imageUnavailable"),
|
||||
});
|
||||
}
|
||||
}, [dispatch, t, uri]);
|
||||
const handleImageLoad = useCallback(
|
||||
(event: ImageLoadEvent) => {
|
||||
if (!uri) {
|
||||
return;
|
||||
}
|
||||
const nativeEvent = event.nativeEvent as ImageLoadEvent["nativeEvent"] & {
|
||||
target?: { naturalWidth?: unknown; naturalHeight?: unknown };
|
||||
};
|
||||
const dimensions = resolveAssistantImageLoadDimensions({
|
||||
source: nativeEvent.source,
|
||||
target: nativeEvent.target,
|
||||
renderedImage: renderedImageRef.current,
|
||||
renderedDimensions,
|
||||
});
|
||||
const metadata = dimensions
|
||||
? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions)
|
||||
: null;
|
||||
const aspectRatio = metadata?.aspectRatio ?? cachedAspectRatio;
|
||||
if (!aspectRatio) {
|
||||
dispatch({
|
||||
type: "failed",
|
||||
uri,
|
||||
message: t("message.attachments.imageUnavailable"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "image_loaded", uri, aspectRatio });
|
||||
},
|
||||
[cachedAspectRatio, dispatch, serverId, source, t, uri, workspaceRoot],
|
||||
);
|
||||
|
||||
const acquisitionFailure = getAcquisitionFailure({
|
||||
hasResolution: resolution !== null,
|
||||
isFileSource: resolution?.kind === "file_rpc",
|
||||
isDataImage: dataImage !== null,
|
||||
fileAttachment,
|
||||
dataImageAttachment,
|
||||
preview,
|
||||
hasDirectUri: directUri !== null,
|
||||
fallbackMessage: t("message.attachments.imagePreviewLoadFailed"),
|
||||
});
|
||||
if (acquisitionFailure) {
|
||||
return acquisitionFailure;
|
||||
}
|
||||
const hasCurrentLifecycleUri = lifecycle.status !== "failed" && lifecycle.uri === uri;
|
||||
let binding: AssistantImageRenderBinding | null = null;
|
||||
if (hasCurrentLifecycleUri && lifecycle.uri) {
|
||||
binding = {
|
||||
uri: lifecycle.uri,
|
||||
onRef: handleImageRef,
|
||||
onLoad: handleImageLoad,
|
||||
onError: handleImageError,
|
||||
};
|
||||
}
|
||||
if (lifecycle.status === "loaded" && lifecycle.uri === uri) {
|
||||
return {
|
||||
status: "loaded",
|
||||
binding: {
|
||||
uri: lifecycle.uri,
|
||||
onRef: handleImageRef,
|
||||
onLoad: handleImageLoad,
|
||||
onError: handleImageError,
|
||||
},
|
||||
aspectRatio: lifecycle.aspectRatio,
|
||||
};
|
||||
}
|
||||
if (lifecycle.status === "failed") {
|
||||
return lifecycle;
|
||||
}
|
||||
return {
|
||||
status: "loading",
|
||||
binding,
|
||||
aspectRatio: (hasCurrentLifecycleUri ? lifecycle.aspectRatio : null) ?? cachedAspectRatio,
|
||||
};
|
||||
}
|
||||
@@ -760,3 +760,58 @@ describe("assistant selection copy inside highlighted code", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("semantic library output", () => {
|
||||
it("copies a partial strong span as plain text", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML =
|
||||
'<div data-testid="assistant-message"><p>Before <strong>bold text</strong> after</p></div>';
|
||||
document.body.append(host);
|
||||
const text = host.querySelector("strong")!.firstChild!;
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 1);
|
||||
range.setEnd(text, 4);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
expect(createAssistantSelectionClipboardContent(selection)?.plainText).toBe("old");
|
||||
expect(createAssistantSelectionClipboardContent(selection)?.html).not.toContain("<strong>");
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
|
||||
it("copies native semantic ordered lists with their original numbers", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML =
|
||||
'<div data-testid="assistant-message"><ol start="3"><li>Third item</li><li>Fourth item</li></ol></div>';
|
||||
document.body.append(host);
|
||||
const selection = selectNodeContents(host.querySelector("ol")!);
|
||||
const content = createAssistantSelectionClipboardContent(selection);
|
||||
expect(content?.plainText).toContain("3. Third item");
|
||||
expect(content?.plainText).toContain("4. Fourth item");
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("copies a selection inside semantic inline code without Markdown escaping", () => {
|
||||
const message = mountFixture();
|
||||
message.innerHTML = "<p>Run <code>apply_patch</code> now.</p>";
|
||||
const code = fixtureElement(message, "code");
|
||||
expect(createAssistantSelectionClipboardContent(selectText(code, 0, 11))?.plainText).toBe(
|
||||
"apply_patch",
|
||||
);
|
||||
});
|
||||
|
||||
it("copies a workspace image path instead of its temporary display URL", () => {
|
||||
const message = mountFixture();
|
||||
const sources = JSON.stringify({ "blob:preview": "assets/chart.png" });
|
||||
message.innerHTML = '<div><img src="blob:preview" alt="chart"></div>';
|
||||
message.firstElementChild!.setAttribute("data-paseo-markdown-image-sources", sources);
|
||||
const selection = window.getSelection()!;
|
||||
const range = document.createRange();
|
||||
range.selectNode(message.querySelector("img")!);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
expect(createAssistantSelectionClipboardContent(selection)?.plainText).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type MarkdownClipboardContent,
|
||||
} from "@/utils/rich-clipboard";
|
||||
import {
|
||||
MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE,
|
||||
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
|
||||
MARKDOWN_COPY_ALT_ATTRIBUTE,
|
||||
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
|
||||
@@ -22,9 +23,38 @@ const ASSISTANT_MESSAGE_SELECTOR = '[data-testid="assistant-message"]';
|
||||
const MESSAGE_ROW_SELECTOR = "[data-message-id]";
|
||||
const CHAT_SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]';
|
||||
const messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`;
|
||||
const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
|
||||
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
|
||||
const IMAGE_FRAME_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="img"]`;
|
||||
const CODE_BLOCK_SELECTOR = `pre, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
|
||||
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, code, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
|
||||
const IMAGE_FRAME_SELECTOR = `img, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="img"]`;
|
||||
|
||||
const SEMANTIC_TAGS =
|
||||
"p,blockquote,br,code,pre,h1,h2,h3,h4,h5,h6,hr,img,li,ol,ul,strong,em,s,del,table,thead,tbody,tr,th,td";
|
||||
function semanticTag(element: Element): string | null {
|
||||
return (
|
||||
element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) ??
|
||||
(element.matches(SEMANTIC_TAGS) ? element.tagName.toLowerCase() : null)
|
||||
);
|
||||
}
|
||||
function copySemanticAttributes(original: Element, copy: Element): void {
|
||||
const tag = semanticTag(original);
|
||||
if (!tag || original.hasAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE)) return;
|
||||
copy.setAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE, tag);
|
||||
if (tag === "ol")
|
||||
copy.setAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE, original.getAttribute("start") ?? "1");
|
||||
if (tag === "img") {
|
||||
const url = original.getAttribute("src") ?? "";
|
||||
const encodedSources = original
|
||||
.closest(`[${MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE}]`)
|
||||
?.getAttribute(MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE);
|
||||
const sources: Record<string, string> = encodedSources ? JSON.parse(encodedSources) : {};
|
||||
copy.setAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE, sources[url] ?? url);
|
||||
copy.setAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE, original.getAttribute("alt") ?? "");
|
||||
}
|
||||
if (tag === "td" || tag === "th") {
|
||||
const align = (original as HTMLElement).style.textAlign;
|
||||
if (align) copy.setAttribute(MARKDOWN_COPY_ALIGN_ATTRIBUTE, align);
|
||||
}
|
||||
}
|
||||
|
||||
const turndown = new TurndownService({
|
||||
bulletListMarker: "-",
|
||||
@@ -247,6 +277,7 @@ function cloneMarkdownSelection(range: Range, message: Element): Node {
|
||||
while (ancestor && ancestor !== message) {
|
||||
if (shouldPreserveSemanticElement(range, ancestor)) {
|
||||
const wrapper = ancestor.cloneNode(false) as Element;
|
||||
copySemanticAttributes(ancestor, wrapper);
|
||||
normalizeOrderedListStart(wrapper, ancestor, range);
|
||||
wrapper.append(selected);
|
||||
selected = wrapper;
|
||||
@@ -294,25 +325,26 @@ function nodeAtPath(root: Node, path: number[]): Node {
|
||||
}
|
||||
|
||||
function removeUnselectedSemantics(range: Range, source: Element, clone: Element): void {
|
||||
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}], a`;
|
||||
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}], a, ${SEMANTIC_TAGS}`;
|
||||
const originals = Array.from(source.querySelectorAll(selector));
|
||||
const copies = Array.from(clone.querySelectorAll(selector));
|
||||
|
||||
originals.forEach((original, index) => {
|
||||
const copy = copies[index];
|
||||
if (copy) copySemanticAttributes(original, copy);
|
||||
if (!copy || shouldPreserveSemanticElement(range, original)) {
|
||||
if (copy) {
|
||||
normalizeOrderedListStart(copy, original, range);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const tag = original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
const tag = semanticTag(original);
|
||||
if (tag && isBlockBoundary(tag)) {
|
||||
copy.setAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE, "p");
|
||||
return;
|
||||
}
|
||||
copy.removeAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
if (copy.tagName === "A") {
|
||||
if (copy.matches(SEMANTIC_TAGS) || copy.tagName === "A") {
|
||||
copy.setAttribute(MARKDOWN_COPY_UNWRAP_ATTRIBUTE, "true");
|
||||
}
|
||||
});
|
||||
@@ -322,7 +354,7 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
if (!range.intersectsNode(element)) {
|
||||
return false;
|
||||
}
|
||||
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
const tag = semanticTag(element);
|
||||
// An image has no part to select, so touching it selects all of it.
|
||||
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
|
||||
return true;
|
||||
@@ -340,8 +372,16 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
(item) => hasSelectedListMarker(range, item) || hasSelectedAllContents(range, item, true),
|
||||
);
|
||||
}
|
||||
if (tag === "li" && hasSelectedListMarker(range, element)) {
|
||||
return true;
|
||||
if (tag === "li") {
|
||||
if (hasSelectedListMarker(range, element)) return true;
|
||||
// CSS list markers are outside text nodes. Starting inside a semantic item
|
||||
// excludes its marker even when all of the label's characters are selected.
|
||||
if (
|
||||
element.tagName === "LI" &&
|
||||
range.startContainer !== element &&
|
||||
element.contains(range.startContainer)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return hasSelectedAllContents(range, element, tag === "li");
|
||||
}
|
||||
@@ -358,7 +398,7 @@ function selectionStaysInsideElement(range: Range, element: Element): boolean {
|
||||
function selectedListItems(list: Element, range: Range): Element[] {
|
||||
return Array.from(list.children).filter(
|
||||
(child) =>
|
||||
child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li" &&
|
||||
semanticTag(child) === "li" &&
|
||||
(child.contains(range.startContainer) ||
|
||||
child.contains(range.endContainer) ||
|
||||
hasSelectedAllContents(range, child, true)),
|
||||
@@ -368,7 +408,7 @@ function selectedListItems(list: Element, range: Range): Element[] {
|
||||
function hasSelectedListMarker(range: Range, item: Element): boolean {
|
||||
const marker = item.querySelector(`:scope > [${MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE}]`);
|
||||
if (!marker) {
|
||||
return false;
|
||||
return item.tagName === "LI" && range.startContainer === item && range.startOffset === 0;
|
||||
}
|
||||
|
||||
const markerContents = document.createRange();
|
||||
@@ -391,18 +431,20 @@ function hasSelectedListMarker(range: Range, item: Element): boolean {
|
||||
}
|
||||
|
||||
function normalizeOrderedListStart(copy: Element, original: Element, range: Range): void {
|
||||
if (original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) !== "ol") {
|
||||
if (semanticTag(original) !== "ol") {
|
||||
return;
|
||||
}
|
||||
const firstSelectedItem = selectedListItems(original, range)[0];
|
||||
if (!firstSelectedItem) {
|
||||
return;
|
||||
}
|
||||
const items = Array.from(original.children).filter(
|
||||
(child) => child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li",
|
||||
);
|
||||
const items = Array.from(original.children).filter((child) => semanticTag(child) === "li");
|
||||
const omittedItems = items.indexOf(firstSelectedItem);
|
||||
const originalStart = Number(original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ?? 1);
|
||||
const originalStart = Number(
|
||||
original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ??
|
||||
original.getAttribute("start") ??
|
||||
1,
|
||||
);
|
||||
copy.setAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE, String(originalStart + omittedItems));
|
||||
}
|
||||
|
||||
|
||||
@@ -19,44 +19,11 @@ export const MARKDOWN_COPY_ALT_ATTRIBUTE = "data-paseo-markdown-alt";
|
||||
export const TRAILING_CODE_LINE_BREAKS = /(\r?\n[ \t]*)+$/;
|
||||
|
||||
export const markdownCopyDataSet = {
|
||||
blockquote: { paseoMarkdownTag: "blockquote" },
|
||||
br: { paseoMarkdownTag: "br" },
|
||||
code: { paseoMarkdownTag: "code" },
|
||||
h1: { paseoMarkdownTag: "h1" },
|
||||
h2: { paseoMarkdownTag: "h2" },
|
||||
h3: { paseoMarkdownTag: "h3" },
|
||||
h4: { paseoMarkdownTag: "h4" },
|
||||
h5: { paseoMarkdownTag: "h5" },
|
||||
h6: { paseoMarkdownTag: "h6" },
|
||||
hr: { paseoMarkdownTag: "hr" },
|
||||
ignore: { paseoMarkdownIgnore: "true" },
|
||||
li: { paseoMarkdownTag: "li" },
|
||||
listMarker: { paseoMarkdownIgnore: "true", paseoMarkdownListMarker: "true" },
|
||||
ol: { paseoMarkdownTag: "ol" },
|
||||
p: { paseoMarkdownTag: "p" },
|
||||
pre: { paseoMarkdownTag: "pre" },
|
||||
s: { paseoMarkdownTag: "s" },
|
||||
strong: { paseoMarkdownTag: "strong" },
|
||||
em: { paseoMarkdownTag: "em" },
|
||||
table: { paseoMarkdownTag: "table" },
|
||||
tbody: { paseoMarkdownTag: "tbody" },
|
||||
td: { paseoMarkdownTag: "td" },
|
||||
th: { paseoMarkdownTag: "th" },
|
||||
thead: { paseoMarkdownTag: "thead" },
|
||||
tr: { paseoMarkdownTag: "tr" },
|
||||
ul: { paseoMarkdownTag: "ul" },
|
||||
unwrap: { paseoMarkdownUnwrap: "true" },
|
||||
ignore: { paseoMarkdownIgnore: "true" },
|
||||
} as const;
|
||||
|
||||
export type MarkdownCopyInlineTag = "br" | "code" | "em" | "s" | "strong";
|
||||
|
||||
export function markdownCopyOrderedListDataSet(start: unknown) {
|
||||
return {
|
||||
...markdownCopyDataSet.ol,
|
||||
paseoMarkdownListStart: String(start ?? 1),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function markdownCopyCodeBlockDataSet(language: string | null | undefined) {
|
||||
const fenceLanguage = language?.trim().split(/\s+/)[0];
|
||||
return {
|
||||
@@ -65,25 +32,7 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined
|
||||
} as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* An image copies as the Markdown it came from. The rendered `img` points at a preview
|
||||
* URL for data and workspace images, and carries no alt text of its own.
|
||||
*/
|
||||
export function markdownCopyImageDataSet(source: string, alt: string | undefined) {
|
||||
return {
|
||||
paseoMarkdownTag: "img",
|
||||
paseoMarkdownSrc: source,
|
||||
...(alt ? { paseoMarkdownAlt: alt } : {}),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) {
|
||||
const alignment =
|
||||
typeof style === "string"
|
||||
? style.match(/(?:^|;)\s*text-align\s*:\s*(left|right|center)/i)?.[1]
|
||||
: null;
|
||||
return {
|
||||
...markdownCopyDataSet[tag],
|
||||
...(alignment ? { paseoMarkdownAlign: alignment.toLowerCase() } : {}),
|
||||
} as const;
|
||||
export const MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE = "data-paseo-markdown-image-sources";
|
||||
export function markdownCopyImageSourcesDataSet(sources: Record<string, string>) {
|
||||
return { paseoMarkdownImageSources: JSON.stringify(sources) };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import {
|
||||
Pressable,
|
||||
View,
|
||||
Text,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Check, Copy } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -96,13 +102,13 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
{keyedLines ? (
|
||||
<MarkdownTextSpan style={innerTextStyle} copyTag="code">
|
||||
<Text style={innerTextStyle} dataSet={markdownCopyDataSet.code}>
|
||||
{renderCodeSegments(keyedLines)}
|
||||
</MarkdownTextSpan>
|
||||
</Text>
|
||||
) : (
|
||||
<MarkdownTextSpan style={innerTextStyle} copyTag="code">
|
||||
<Text style={innerTextStyle} dataSet={markdownCopyDataSet.code}>
|
||||
{renderedCode}
|
||||
</MarkdownTextSpan>
|
||||
</Text>
|
||||
)}
|
||||
<CopyButton getCode={getCode} visible={controlsVisible} />
|
||||
</View>
|
||||
@@ -129,9 +135,7 @@ interface TokenSpanProps {
|
||||
|
||||
const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) {
|
||||
return (
|
||||
<MarkdownTextSpan style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
|
||||
{token.text}
|
||||
</MarkdownTextSpan>
|
||||
<Text style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>{token.text}</Text>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -140,7 +144,7 @@ interface CodeTextSpanProps {
|
||||
}
|
||||
|
||||
const CodeTextSpan = React.memo(function CodeTextSpan({ text }: CodeTextSpanProps) {
|
||||
return <MarkdownTextSpan>{text}</MarkdownTextSpan>;
|
||||
return <Text>{text}</Text>;
|
||||
});
|
||||
|
||||
interface SplitStyles {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { MarkdownProse } from "./renderer";
|
||||
export { groupMarkdownProse } from "./runs";
|
||||
@@ -1,217 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
prepareProse,
|
||||
createProsePreparer,
|
||||
inlineCodeLinkContent,
|
||||
proseLinkSource,
|
||||
handleProseLinkPress,
|
||||
} from "./prepare";
|
||||
|
||||
const noFiles = () => false;
|
||||
describe("source-preserving prose", () => {
|
||||
it.each([
|
||||
["http://[::1]:3000", "<http://[::1]:3000>"],
|
||||
["[http://[::1]:3000](http://[::1]:3000)", "[http\\://[::1]:3000](<http://[::1]:3000>)"],
|
||||
["http://[::1]:3000/path?x=[1]", "<http://[::1]:3000/path?x=[1]>"],
|
||||
["###### a", "###### A"],
|
||||
])("preserves IPv6 links and decoded heading case: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(expected);
|
||||
});
|
||||
it.each([
|
||||
[
|
||||
"https://example.com/?tags[]=a",
|
||||
"[https://example.com/?tags[]=a](<https://example.com/?tags%5B%5D=a>)",
|
||||
],
|
||||
["`src/[...]/file.ts`", "`src/[...]/file.ts`"],
|
||||
["[text []](https://example.com)", "[text []](https://example.com)"],
|
||||
["https://example.com/a*b*c", "[https://example.com/a](<https://example.com/a>)*b*c"],
|
||||
])("uses parser-owned link ranges: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(expected);
|
||||
});
|
||||
it.each([
|
||||
["Paths ~/.config and ~/.local are used.", "Paths \\~/.config and \\~/.local are used."],
|
||||
["~/a ~/b", "\\~/a \\~/b"],
|
||||
["~~gone~~", "~~gone~~"],
|
||||
["a ~ b", "a \\~ b"],
|
||||
["`~~` and `~/a ~/b`", "`~~` and `~/a ~/b`"],
|
||||
])("keeps single tildes literal: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(expected);
|
||||
});
|
||||
it.each([
|
||||
["https://example.com/a&b", "[https://example.com/a&b](<https://example.com/a&b>)"],
|
||||
["https://example.com/é", "[https://example.com/é](<https://example.com/%C3%A9>)"],
|
||||
["https://example.com/a\\_b", "[https://example.com/a\\_b](<https://example.com/a_b>)"],
|
||||
])("wraps linkify's normalized destination: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(expected);
|
||||
});
|
||||
it.each([
|
||||
"A & B",
|
||||
"# A & B",
|
||||
"- A & B",
|
||||
"> A & B",
|
||||
"Escaped \\* and \\|",
|
||||
"<https://example.org>",
|
||||
"**bold** and *emphasis* and ~~deleted~~",
|
||||
"First \nSecond",
|
||||
"# #",
|
||||
"Heading\n=======",
|
||||
'A [titled link](https://example.com "A title")',
|
||||
"[linked `code`](https://example.net)",
|
||||
"Before  after.",
|
||||
"Blank ` ` code and ` normal ` code",
|
||||
"\n\nA paragraph.\n\n",
|
||||
"- [ ] Task\n- [x] Done",
|
||||
"> [!NOTE]\n> Note",
|
||||
"> Ordinary quote",
|
||||
])("copies unchanged source byte-for-byte: %s", (source) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(source);
|
||||
});
|
||||
it.each([
|
||||
["[file](file:///repo/a&b.txt)", "[file](<file:///repo/a&b.txt>)"],
|
||||
["", ""],
|
||||
["- first\n\t& second", "- first\n & second"],
|
||||
["[foo\nbar]", "\\[foo\nbar]"],
|
||||
["[foo\\*bar]", "\\[foo\\*bar]"],
|
||||
])("preserves decoded destinations and source positions: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(expected);
|
||||
});
|
||||
it.each([
|
||||
[" ---", "\\\n\\---"],
|
||||
["Before # after", "Before\\\n\\# after"],
|
||||
[" 1. next", "\\\n1\\. next"],
|
||||
["	---", "\u00a0\u00a0\u00a0\u00a0---"],
|
||||
[" ---", "\u00a0---"],
|
||||
["Before after", "Before\\\nafter"],
|
||||
])("keeps whitespace entities inside text: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(expected);
|
||||
});
|
||||
it("keeps multiline and escaped labels local to each source block", () => {
|
||||
expect(prepareProse(["[foo\nbar]", "Other", "[foo bar]: https://example.com"], noFiles)).toBe(
|
||||
"\\[foo\nbar]\n\nOther\n\n[foo bar]: https://example.com",
|
||||
);
|
||||
expect(
|
||||
prepareProse(["[foo\\*bar]", "Other", "[foo\\*bar]: https://example.com"], noFiles),
|
||||
).toBe("\\[foo\\*bar]\n\nOther\n\n[foo\\*bar]: https://example.com");
|
||||
});
|
||||
it("prepares only changed source blocks on append", () => {
|
||||
const candidates: string[] = [];
|
||||
const prepare = createProsePreparer((text) => {
|
||||
candidates.push(text);
|
||||
return false;
|
||||
});
|
||||
prepare(["First `one`.", "Next `two`.", "Last `three`."], Infinity);
|
||||
candidates.length = 0;
|
||||
prepare(["First `one`.", "Next `two`.", "Last `three`. More."], Infinity);
|
||||
expect(candidates).toEqual(["three"]);
|
||||
});
|
||||
it("bounds preparation before parsing a capped block", () => {
|
||||
let candidates = 0;
|
||||
const prepare = createProsePreparer(() => {
|
||||
candidates++;
|
||||
return false;
|
||||
});
|
||||
const source = "Use `foo` here. ".repeat(70_000);
|
||||
prepare([source], 32_000);
|
||||
expect(candidates).toBeLessThan(2200);
|
||||
});
|
||||
it("decodes entities outside code", () => {
|
||||
expect(prepareProse(["A & B A A © `&`"], noFiles)).toBe(
|
||||
"A & B A A © `&`",
|
||||
);
|
||||
});
|
||||
it("decodes entities once, including encoded entity syntax", () => {
|
||||
expect(prepareProse(["Literal &amp; and &copy;"], noFiles)).toBe(
|
||||
"Literal \\& and \\©",
|
||||
);
|
||||
});
|
||||
it.each(["\r", "\r\n"])("normalizes line endings %j", (ending) => {
|
||||
expect(prepareProse([`First${ending}Second ||text||`], noFiles)).toBe(
|
||||
"First\nSecond \\|\\|text\\|\\|",
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
["See example.com.", "See [example.com](<http://example.com>)."],
|
||||
["https://example.com/a_(b).", "[https://example.com/a_(b)](<https://example.com/a_(b)>)."],
|
||||
["https://example.com/a.", "[https://example.com/a](<https://example.com/a>)."],
|
||||
])("makes linkify destinations explicit: %s", (source, expected) => {
|
||||
expect(prepareProse([source], noFiles)).toBe(expected);
|
||||
});
|
||||
it("only exempts task markers inside list items", () => {
|
||||
expect(prepareProse(["[x]", "Other\n\n[x]: https://example.com"], noFiles)).toBe(
|
||||
"\\[x]\n\nOther\n\n[x]: https://example.com",
|
||||
);
|
||||
const tasks = "- [x] Task\n- [ ] Pending\n1. [x] Ordered";
|
||||
expect(prepareProse([tasks], noFiles)).toBe(tasks);
|
||||
});
|
||||
it("prepares 30k of inline code in under 50ms warm", () => {
|
||||
const text = "Use `foo` and `bar` here. ".repeat(1300).slice(0, 30000);
|
||||
for (let i = 0; i < 10; i++) prepareProse([text], noFiles);
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 5; i++) expect(prepareProse([text], noFiles)).toBe(text);
|
||||
expect((performance.now() - start) / 5).toBeLessThan(50);
|
||||
});
|
||||
it("changes only prose pipes", () => {
|
||||
expect(prepareProse(["Keep ||literal|| and `a || b` and [a|b](https://x/a|b)."], noFiles)).toBe(
|
||||
"Keep \\|\\|literal\\|\\| and `a || b` and [a\\|b](https://x/a|b).",
|
||||
);
|
||||
});
|
||||
it("wraps inline file code without reprinting its source", () => {
|
||||
const source = "[Docs](https://example.com) and `` src/file.ts:12 ``";
|
||||
expect(prepareProse([source], (text) => text === "src/file.ts:12")).toBe(
|
||||
"[Docs](https://example.com) and [`` src/file.ts:12 ``](<paseo-inline-code:src%2Ffile.ts%3A12>)",
|
||||
);
|
||||
expect(inlineCodeLinkContent("paseo-inline-code:src%2Ffile.ts%3A12")).toBe("src/file.ts:12");
|
||||
});
|
||||
it("keeps inline-code URLs tappable", () => {
|
||||
expect(prepareProse(["`https://example.com`"], noFiles)).toBe(
|
||||
"[`https://example.com`](<https://example.com>)",
|
||||
);
|
||||
});
|
||||
it("uppercases only h6 text", () => {
|
||||
expect(
|
||||
prepareProse(
|
||||
["###### Small **bold** `lower.ts` [link](https://example.com/lower) &"],
|
||||
noFiles,
|
||||
),
|
||||
).toBe("###### SMALL **BOLD** `lower.ts` [LINK](https://example.com/lower) &");
|
||||
});
|
||||
it("prepares source blocks independently, even beside a reference definition", () => {
|
||||
const blocks = ["![shot][pic]", "Other paragraph.\n\n[pic]: shots/app.png"];
|
||||
expect(prepareProse(blocks, noFiles)).toBe(
|
||||
blocks.map((block) => prepareProse([block], noFiles)).join("\n\n"),
|
||||
);
|
||||
expect(prepareProse(blocks, noFiles)).toBe(
|
||||
"!\\[shot]\\[pic]\n\nOther paragraph.\n\n[pic]: shots/app.png",
|
||||
);
|
||||
});
|
||||
it("localizes resolved references before joining blocks with conflicting definitions", () => {
|
||||
const blocks = [
|
||||
"[foo][ref]\n\n[ref]: https://one.example",
|
||||
"[bar][ref]\n\n[ref]: https://two.example",
|
||||
];
|
||||
expect(prepareProse(blocks, noFiles)).toBe(
|
||||
"[foo](<https://one.example>)\n\n[ref]: https://one.example\n\n[bar](<https://two.example>)\n\n[ref]: https://two.example",
|
||||
);
|
||||
});
|
||||
it("carries a bare filename's text and href through the file action", () => {
|
||||
const prepared = prepareProse(["Open README.md"], (text) => text === "README.md");
|
||||
const source = {
|
||||
href: "http://README.md",
|
||||
text: "README.md",
|
||||
markup: "linkify",
|
||||
sourceInfo: "auto",
|
||||
};
|
||||
const url = `paseo-file-link:${encodeURIComponent(JSON.stringify(source))}`;
|
||||
expect(prepared).toBe(`Open [README.md](<${url}>)`);
|
||||
expect(proseLinkSource(url)).toEqual(source);
|
||||
const opened: unknown[] = [];
|
||||
handleProseLinkPress(
|
||||
url,
|
||||
(value) => opened.push(value),
|
||||
() => {
|
||||
throw new Error("Opened website");
|
||||
},
|
||||
);
|
||||
expect(opened).toEqual([source]);
|
||||
});
|
||||
});
|
||||
@@ -1,429 +0,0 @@
|
||||
import type { AssistantFileLinkSource } from "@/assistant-file-links";
|
||||
import type Token from "markdown-it/lib/token.mjs";
|
||||
import { type InlineSpan, type InlineTrace, createSourceParser } from "./source-inline";
|
||||
|
||||
const settled = createSourceParser();
|
||||
const INLINE_CODE_SCHEME = "paseo-inline-code:";
|
||||
const FILE_LINK_SCHEME = "paseo-file-link:";
|
||||
|
||||
export function inlineCodeLinkContent(url: string): string | null {
|
||||
return url.startsWith(INLINE_CODE_SCHEME)
|
||||
? decodeURIComponent(url.slice(INLINE_CODE_SCHEME.length))
|
||||
: null;
|
||||
}
|
||||
export function proseLinkSource(url: string): AssistantFileLinkSource | null {
|
||||
try {
|
||||
const code = inlineCodeLinkContent(url);
|
||||
if (code !== null) return { href: code, text: code, sourceType: "inline-code" };
|
||||
if (!url.startsWith(FILE_LINK_SCHEME)) return null;
|
||||
const source: unknown = JSON.parse(decodeURIComponent(url.slice(FILE_LINK_SCHEME.length)));
|
||||
if (
|
||||
!source ||
|
||||
typeof source !== "object" ||
|
||||
!("href" in source) ||
|
||||
!("text" in source) ||
|
||||
typeof source.href !== "string" ||
|
||||
typeof source.text !== "string"
|
||||
)
|
||||
return null;
|
||||
return { href: source.href, text: source.text, markup: "linkify", sourceInfo: "auto" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleProseLinkPress(
|
||||
url: string,
|
||||
open: (source: AssistantFileLinkSource) => void,
|
||||
onLinkPress: (url: string) => unknown,
|
||||
): void {
|
||||
const source = proseLinkSource(url);
|
||||
if (source) open(source);
|
||||
else onLinkPress(url);
|
||||
}
|
||||
interface Edit {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
function applyEdits(source: string, edits: Edit[]): string {
|
||||
let cursor = 0;
|
||||
let result = "";
|
||||
for (const edit of edits.sort((a, b) => a.start - b.start || a.end - b.end)) {
|
||||
if (edit.start < cursor) continue;
|
||||
result += source.slice(cursor, edit.start) + edit.text;
|
||||
cursor = edit.end;
|
||||
}
|
||||
return result + source.slice(cursor);
|
||||
}
|
||||
|
||||
/** Each completed source block retains its own reference scope. */
|
||||
export function prepareProse(
|
||||
blocks: readonly string[],
|
||||
canResolveCode: (text: string) => boolean,
|
||||
visibleLength = Infinity,
|
||||
): string {
|
||||
return createProsePreparer(canResolveCode)(blocks, visibleLength);
|
||||
}
|
||||
|
||||
/** One cache per prose row; joining a completed block does not reparse earlier blocks. */
|
||||
export function createProsePreparer(canResolveCode: (text: string) => boolean) {
|
||||
let cache = new Map<string, string>();
|
||||
return (blocks: readonly string[], visibleLength = Infinity): string => {
|
||||
const retained = new Map<string, string>();
|
||||
const result: string[] = [];
|
||||
let remaining = visibleLength;
|
||||
for (const block of blocks) {
|
||||
if (remaining < 0) break;
|
||||
const source = block.slice(0, remaining);
|
||||
const prepared = cache.get(source) ?? prepareBlock(source, canResolveCode);
|
||||
retained.set(source, prepared);
|
||||
result.push(prepared);
|
||||
remaining -= block.length + 2;
|
||||
}
|
||||
cache = retained;
|
||||
return result.join("\n\n");
|
||||
};
|
||||
}
|
||||
|
||||
interface InlinePreparation {
|
||||
source: string;
|
||||
token: Token;
|
||||
trace: InlineTrace;
|
||||
parser: typeof settled.parser;
|
||||
env: object;
|
||||
uppercase: boolean;
|
||||
canResolveCode: (text: string) => boolean;
|
||||
protectedRanges: Uint8Array;
|
||||
referenceRanges: Uint8Array;
|
||||
task: boolean;
|
||||
edits: Edit[];
|
||||
}
|
||||
function insert(context: InlinePreparation, at: number, text: string) {
|
||||
context.edits.push({ start: at, end: at, text });
|
||||
}
|
||||
function contains(ranges: Uint8Array, start: number, end = start + 1): boolean {
|
||||
for (let i = start; i < end; i++) if (ranges[i]) return true;
|
||||
return false;
|
||||
}
|
||||
function protect(ranges: Uint8Array, span: { start: number; end: number }) {
|
||||
ranges.fill(1, span.start, span.end);
|
||||
}
|
||||
function codeUrl(context: InlinePreparation, content: string): string | null {
|
||||
if (context.canResolveCode(content)) return INLINE_CODE_SCHEME + encodeURIComponent(content);
|
||||
const matches = context.parser.linkify.match(content);
|
||||
const match = matches?.length === 1 ? matches[0] : undefined;
|
||||
return match?.index === 0 && match.lastIndex === content.length ? match.url : null;
|
||||
}
|
||||
function prepareCode(context: InlinePreparation, span: InlineSpan, code: Token) {
|
||||
protect(context.protectedRanges, span);
|
||||
protect(context.referenceRanges, span);
|
||||
const url = codeUrl(context, code.content);
|
||||
if (!span.insideLink && url) {
|
||||
insert(context, span.start, "[");
|
||||
insert(context, span.end, `](<${url}>)`);
|
||||
}
|
||||
}
|
||||
function labelEnd(context: InlinePreparation, source: string, start: number): number {
|
||||
const state = new context.parser.inline.State(source, context.parser, context.env, []);
|
||||
return context.parser.helpers.parseLinkLabel(state, start, false);
|
||||
}
|
||||
function preserveLinkScope(context: InlinePreparation, span: InlineSpan, raw: string, link: Token) {
|
||||
const end = labelEnd(context, raw, 0);
|
||||
protect(context.protectedRanges, { start: span.start + end, end: span.end });
|
||||
protect(context.referenceRanges, span);
|
||||
// MD4C's permissive autolink scan otherwise consumes the bracketed host
|
||||
// inside a link label before the explicit link is recognized.
|
||||
const ipv6Label = /^https?:\/\/\[/.exec(raw.slice(1, end));
|
||||
if (ipv6Label) insert(context, span.start + 1 + ipv6Label[0].indexOf(":"), "\\");
|
||||
if (raw[end + 1] === "(") {
|
||||
decodeDestination(context, span, raw, end, link.attrGet("href")!);
|
||||
return;
|
||||
}
|
||||
// The native parser sees the joined run; bind reference destinations to their source block.
|
||||
const title = link.attrGet("title");
|
||||
const quotedTitle =
|
||||
title === null ? "" : ` "${title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
context.edits.push({
|
||||
start: span.start + end,
|
||||
end: span.end,
|
||||
text: `](<${link.attrGet("href")}>${quotedTitle})`,
|
||||
});
|
||||
}
|
||||
function decodeDestination(
|
||||
context: InlinePreparation,
|
||||
span: InlineSpan,
|
||||
raw: string,
|
||||
label: number,
|
||||
href: string,
|
||||
) {
|
||||
if (raw[label + 1] !== "(") return;
|
||||
const start = label + 2 + (raw.slice(label + 2).match(/^\s*/)?.[0].length ?? 0);
|
||||
const destination = context.parser.helpers.parseLinkDestination(raw, start, raw.length);
|
||||
if (!destination.ok) return;
|
||||
const original = raw.slice(start, destination.pos);
|
||||
if (context.parser.utils.unescapeAll(original) === original && !/[[\]]/.test(original)) return;
|
||||
href = href.replace(/^(https?:\/\/)%5B([^/]+)%5D/i, "$1[$2]");
|
||||
context.edits.push({
|
||||
start: span.start + start,
|
||||
end: span.start + destination.pos,
|
||||
text: `<${href}>`,
|
||||
});
|
||||
}
|
||||
function entityWhitespace(
|
||||
context: InlinePreparation,
|
||||
span: InlineSpan,
|
||||
decoded: string,
|
||||
): string | null {
|
||||
if (!/^[\t\n\r\f\v ]+$/.test(decoded)) return null;
|
||||
if (!/[\r\n]/.test(decoded))
|
||||
return decoded.replace(/\t/g, "\u00a0\u00a0\u00a0\u00a0").replace(/[\f\v ]/g, "\u00a0");
|
||||
// A text-token newline must not start a block. Backslash breaks also work at
|
||||
// the beginning of a paragraph, where two-space breaks would be trimmed away.
|
||||
const following = context.source.slice(span.end);
|
||||
const marker =
|
||||
/^[ \t]*(?:[-=]+[ \t]*(?:\n|$)|(?:[-*_][ \t]*){3,}(?:\n|$)|#{1,6}(?:[ \t]|$)|>|\d{1,9}[.)][ \t]|[-+*][ \t]|`{3,}|~{3,}|<)/.exec(
|
||||
following,
|
||||
);
|
||||
if (marker) {
|
||||
const punctuation = marker[0].search(/[^ \t\d]/);
|
||||
insert(context, span.end + punctuation, "\\");
|
||||
}
|
||||
return decoded.replace(/\r\n?|\n/g, "\\\n");
|
||||
}
|
||||
function decodeEntity(context: InlinePreparation, span: InlineSpan, raw: string) {
|
||||
// MD4C 1.1.1 drops entity nodes. Decode once without introducing Markdown syntax.
|
||||
const value = context.parser.utils.unescapeAll(raw);
|
||||
const decoded = context.uppercase ? value.toUpperCase() : value;
|
||||
let text =
|
||||
entityWhitespace(context, span, decoded) ??
|
||||
decoded.replace(/([\\`*{}[\]()#+.!_<>|~-])/g, "\\$1");
|
||||
if (decoded.endsWith("&") && /^(?:#\w+|\w+);/.test(context.source.slice(span.end)))
|
||||
text = text.slice(0, -1) + "\\&";
|
||||
context.edits.push({ start: span.start, end: span.end, text });
|
||||
protect(context.protectedRanges, span);
|
||||
}
|
||||
function prepareSpan(context: InlinePreparation, span: InlineSpan) {
|
||||
const raw = context.source.slice(span.start, span.end);
|
||||
const link = span.tokens.find((child) => child.type === "link_open");
|
||||
const image = span.tokens.find((child) => child.type === "image");
|
||||
const code = span.tokens.find((child) => child.type === "code_inline");
|
||||
if (image) {
|
||||
decodeDestination(context, span, raw, labelEnd(context, raw, 1), image.attrGet("src")!);
|
||||
protect(context.protectedRanges, span);
|
||||
protect(context.referenceRanges, span);
|
||||
} else if (raw.startsWith("\\") || (link && raw.startsWith("<"))) {
|
||||
protect(context.protectedRanges, span);
|
||||
protect(context.referenceRanges, span);
|
||||
} else if (/^&(?:#\w+|\w+);$/.test(raw)) {
|
||||
decodeEntity(context, span, raw);
|
||||
} else if (link && raw.startsWith("[")) {
|
||||
preserveLinkScope(context, span, raw, link);
|
||||
} else if (code && raw.startsWith("`")) {
|
||||
prepareCode(context, span, code);
|
||||
}
|
||||
}
|
||||
|
||||
function preserveUnresolvedReferences(context: InlinePreparation) {
|
||||
// Protect every unresolved label, regardless of CommonMark's case/whitespace
|
||||
// normalization. Escapes inside a label do not make its opening bracket safe.
|
||||
const openings: number[] = [];
|
||||
for (let index = 0; index < context.source.length; index++) {
|
||||
if (contains(context.referenceRanges, index)) continue;
|
||||
if (context.source[index] === "[") openings.push(index);
|
||||
if (context.source[index] !== "]") continue;
|
||||
const start = openings.pop();
|
||||
if (start === undefined) continue;
|
||||
const label = context.source.slice(start, index + 1);
|
||||
if (/^\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/.test(label)) continue;
|
||||
if (
|
||||
context.task &&
|
||||
start === 0 &&
|
||||
/^\[[ xX]\]$/.test(label) &&
|
||||
/\s/.test(context.source[index + 1] ?? "")
|
||||
)
|
||||
continue;
|
||||
insert(context, start, "\\");
|
||||
}
|
||||
}
|
||||
function prepareText(context: InlinePreparation) {
|
||||
for (let i = 0; i < context.source.length; i++) {
|
||||
if (contains(context.protectedRanges, i)) continue;
|
||||
const char = context.source[i]!;
|
||||
if (
|
||||
char === "|" ||
|
||||
(char === "~" && context.source[i - 1] !== "~" && context.source[i + 1] !== "~")
|
||||
)
|
||||
insert(context, i, "\\");
|
||||
if (context.uppercase && char.toUpperCase() !== char)
|
||||
context.edits.push({ start: i, end: i + 1, text: char.toUpperCase() });
|
||||
}
|
||||
}
|
||||
/** Linkify sees decoded inline text; keep its offsets tied to the original Markdown. */
|
||||
function linkifySource(context: InlinePreparation) {
|
||||
const positions: number[] = [];
|
||||
let decoded = "";
|
||||
let cursor = 0;
|
||||
const escapes = /\\[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]|&[a-z#][a-z0-9]{1,31};/gi;
|
||||
function appendPlain(end: number) {
|
||||
while (cursor < end) {
|
||||
positions.push(cursor);
|
||||
decoded += context.source[cursor++];
|
||||
}
|
||||
}
|
||||
for (const match of context.source.matchAll(escapes)) {
|
||||
appendPlain(match.index);
|
||||
const text = context.parser.utils.unescapeAll(match[0]);
|
||||
for (let index = 0; index < text.length; index++) positions.push(cursor);
|
||||
decoded += text;
|
||||
cursor += match[0].length;
|
||||
}
|
||||
appendPlain(context.source.length);
|
||||
positions.push(cursor);
|
||||
return { decoded, positions };
|
||||
}
|
||||
function prepareLinkifiedLinks(context: InlinePreparation) {
|
||||
const links =
|
||||
context.token.children?.filter(
|
||||
(child) => child.type === "link_open" && child.markup === "linkify",
|
||||
) ?? [];
|
||||
if (!links.length && !context.source.includes("://[")) return;
|
||||
const destinations = new Map(links.map((link) => [link.attrGet("href"), link]));
|
||||
const { decoded, positions } = linkifySource(context);
|
||||
let cursor = 0;
|
||||
for (const text of context.trace.linkifyTexts) {
|
||||
if (!text) continue;
|
||||
let offset = decoded.indexOf(text, cursor);
|
||||
while (
|
||||
offset >= 0 &&
|
||||
contains(context.referenceRanges, positions[offset]!, positions[offset + text.length]!)
|
||||
)
|
||||
offset = decoded.indexOf(text, offset + 1);
|
||||
if (offset < 0) continue;
|
||||
cursor = offset + text.length;
|
||||
prepareIpv6Links(context, text, offset, positions);
|
||||
for (const match of context.parser.linkify.match(text) ?? []) {
|
||||
const link = destinations.get(context.parser.normalizeLink(match.url));
|
||||
if (!link) continue;
|
||||
wrapLinkifiedMatch(
|
||||
context,
|
||||
positions[offset + match.index]!,
|
||||
positions[offset + match.lastIndex]!,
|
||||
link.attrGet("href")!,
|
||||
match.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/** linkify-it omits bracketed hosts; bind those URLs in plain parser text explicitly. */
|
||||
function prepareIpv6Links(
|
||||
context: InlinePreparation,
|
||||
text: string,
|
||||
offset: number,
|
||||
positions: number[],
|
||||
) {
|
||||
for (const match of text.matchAll(/https?:\/\/\[[0-9a-f:.]+\](?::\d+)?(?:[/?#][^\s<>]*)?/gi)) {
|
||||
try {
|
||||
if (!new URL(match[0]).hostname.startsWith("[")) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const start = positions[offset + match.index]!;
|
||||
const end = positions[offset + match.index + match[0].length]!;
|
||||
if (contains(context.referenceRanges, start, end)) continue;
|
||||
protect(context.referenceRanges, { start, end });
|
||||
protect(context.protectedRanges, { start, end });
|
||||
insert(context, start, "<");
|
||||
insert(context, end, ">");
|
||||
}
|
||||
}
|
||||
function wrapLinkifiedMatch(
|
||||
context: InlinePreparation,
|
||||
start: number,
|
||||
end: number,
|
||||
destination: string,
|
||||
text: string,
|
||||
) {
|
||||
const payload: AssistantFileLinkSource = {
|
||||
href: destination,
|
||||
text,
|
||||
markup: "linkify",
|
||||
sourceInfo: "auto",
|
||||
};
|
||||
const href = context.canResolveCode(text)
|
||||
? FILE_LINK_SCHEME + encodeURIComponent(JSON.stringify(payload))
|
||||
: destination;
|
||||
protect(context.referenceRanges, { start, end });
|
||||
insert(context, start, "[");
|
||||
insert(context, end, `](<${href}>)`);
|
||||
}
|
||||
|
||||
function sourcePositions(
|
||||
token: Token,
|
||||
heading: Token | undefined,
|
||||
lines: string[],
|
||||
lineOffsets: number[],
|
||||
): number[] {
|
||||
const positions: number[] = [];
|
||||
for (const [index, content] of token.content.split("\n").entries()) {
|
||||
const row = token.map![0] + index;
|
||||
const rawLine = lines[row]!;
|
||||
let headingPrefix = 0;
|
||||
if (heading?.type === "heading_open" && heading.markup.startsWith("#"))
|
||||
headingPrefix = rawLine.match(/^(?:\s*>\s*)*\s*#{1,6}\s+/)?.[0].length ?? 0;
|
||||
const column = rawLine.indexOf(content, headingPrefix);
|
||||
const start = lineOffsets[row]! + Math.max(0, column);
|
||||
for (let i = 0; i <= content.length; i++) positions.push(start + i);
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
function prepareBlock(text: string, canResolveCode: (text: string) => boolean): string {
|
||||
text = text.replace(/\r\n?/g, "\n").replaceAll("\0", "\ufffd");
|
||||
text = text.replace(/^[ \t>]*/gm, (prefix) => {
|
||||
let expanded = "";
|
||||
for (const char of prefix)
|
||||
expanded += char === "\t" ? " ".repeat(4 - (expanded.length % 4)) : char;
|
||||
return expanded;
|
||||
});
|
||||
const { parser, traces } = settled;
|
||||
const env = {};
|
||||
const tokens = parser.parse(text, env);
|
||||
const lines = text.split("\n");
|
||||
const lineOffsets: number[] = [];
|
||||
let offset = 0;
|
||||
for (const line of lines) {
|
||||
lineOffsets.push(offset);
|
||||
offset += line.length + 1;
|
||||
}
|
||||
const edits: Edit[] = [];
|
||||
for (const [index, token] of tokens.entries()) {
|
||||
if (token.type !== "inline" || !token.map) continue;
|
||||
const trace = traces.get(token)!;
|
||||
const heading = tokens[index - 1];
|
||||
const context: InlinePreparation = {
|
||||
source: token.content,
|
||||
token,
|
||||
trace,
|
||||
parser,
|
||||
env,
|
||||
uppercase: heading?.type === "heading_open" && heading.tag === "h6",
|
||||
canResolveCode,
|
||||
protectedRanges: new Uint8Array(token.content.length),
|
||||
referenceRanges: new Uint8Array(token.content.length),
|
||||
task: tokens[index - 2]?.type === "list_item_open",
|
||||
edits: [],
|
||||
};
|
||||
// Link/code ownership is established before references can be rewritten. Linkify uses the parser's emphasis-split text tokens.
|
||||
for (const span of trace.spans)
|
||||
if (span.tokens.some((child) => ["link_open", "code_inline", "image"].includes(child.type)))
|
||||
protect(context.referenceRanges, span);
|
||||
prepareLinkifiedLinks(context);
|
||||
for (const span of trace.spans) prepareSpan(context, span);
|
||||
preserveUnresolvedReferences(context);
|
||||
prepareText(context);
|
||||
const positions = sourcePositions(token, heading, lines, lineOffsets);
|
||||
for (const edit of context.edits)
|
||||
edits.push({ ...edit, start: positions[edit.start]!, end: positions[edit.end]! });
|
||||
}
|
||||
return applyEdits(text, edits);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface MarkdownProseProps {
|
||||
text: string;
|
||||
sourceBlocks: readonly string[];
|
||||
kind: "prose" | "quote" | "block";
|
||||
onLinkPress: (url: string) => boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { EnrichedMarkdownText, type MarkdownStyle } from "react-native-enriched-markdown";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useAssistantFileLinkActions } from "@/assistant-file-links";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { MarkdownProseProps } from "./props";
|
||||
import { handleProseLinkPress, createProsePreparer } from "./prepare";
|
||||
|
||||
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
|
||||
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
|
||||
|
||||
function proseStyleMapping(theme: Theme): { markdownStyle: MarkdownStyle } {
|
||||
const styles = createMarkdownStyles(theme);
|
||||
const paragraph = {
|
||||
color: styles.body.color,
|
||||
fontSize: styles.body.fontSize,
|
||||
lineHeight: styles.body.lineHeight,
|
||||
marginTop: 0,
|
||||
marginBottom: styles.paragraph.marginBottom + theme.spacing[3],
|
||||
};
|
||||
return {
|
||||
markdownStyle: {
|
||||
paragraph,
|
||||
h1: {
|
||||
...styles.heading1,
|
||||
marginBottom:
|
||||
styles.heading1.marginBottom + theme.spacing[3] + styles.heading1.paddingBottom,
|
||||
},
|
||||
h2: {
|
||||
...styles.heading2,
|
||||
marginBottom:
|
||||
styles.heading2.marginBottom + theme.spacing[3] + styles.heading2.paddingBottom,
|
||||
},
|
||||
h3: { ...styles.heading3, marginBottom: styles.heading3.marginBottom + theme.spacing[3] },
|
||||
h4: { ...styles.heading4, marginBottom: styles.heading4.marginBottom + theme.spacing[3] },
|
||||
h5: { ...styles.heading5, marginBottom: styles.heading5.marginBottom + theme.spacing[3] },
|
||||
h6: { ...styles.heading6, marginBottom: styles.heading6.marginBottom + theme.spacing[3] },
|
||||
list: {
|
||||
...paragraph,
|
||||
bulletColor: styles.bullet_list_icon.color,
|
||||
markerColor: styles.ordered_list_icon.color,
|
||||
gapWidth: 7,
|
||||
itemSpacing: styles.list_item.marginBottom,
|
||||
marginLeft: theme.spacing[4],
|
||||
},
|
||||
blockquote: {
|
||||
...paragraph,
|
||||
color: styles.blockquote.color,
|
||||
backgroundColor: styles.blockquote.backgroundColor,
|
||||
borderColor: styles.blockquote.borderLeftColor,
|
||||
borderWidth: styles.blockquote.borderLeftWidth,
|
||||
borderRadius: styles.blockquote.borderRadius,
|
||||
padding: styles.blockquote.paddingTop,
|
||||
gapWidth: 4,
|
||||
marginTop: styles.blockquote.marginVertical,
|
||||
marginBottom: styles.blockquote.marginVertical,
|
||||
},
|
||||
link: { color: styles.link.color, underline: false },
|
||||
code: {
|
||||
// RCTFont does not resolve Fabric's ui-monospace alias. An empty family
|
||||
// selects the library's native system-monospace font on iOS.
|
||||
fontFamily:
|
||||
styles.code_inline.fontFamily === "ui-monospace" ? "" : styles.code_inline.fontFamily,
|
||||
fontSize: styles.code_inline.fontSize,
|
||||
color: styles.code_inline.color,
|
||||
backgroundColor: styles.code_inline.backgroundColor,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
strong: { fontWeight: "bold" },
|
||||
em: styles.em,
|
||||
strikethrough: { color: styles.s.color },
|
||||
thematicBreak: {
|
||||
color: styles.hr.backgroundColor,
|
||||
height: styles.hr.height,
|
||||
marginTop: styles.hr.marginVertical,
|
||||
marginBottom: styles.hr.marginVertical,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function quoteStyleMapping(theme: Theme): { markdownStyle: MarkdownStyle } {
|
||||
const mapped = proseStyleMapping(theme);
|
||||
return {
|
||||
markdownStyle: {
|
||||
...mapped.markdownStyle,
|
||||
paragraph: { ...mapped.markdownStyle.paragraph, marginBottom: theme.spacing[3] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const PROSE_CONTAINER = { marginBottom: -12 };
|
||||
|
||||
const NativeProse = memo(function NativeProse({
|
||||
text,
|
||||
sourceBlocks,
|
||||
kind,
|
||||
onLinkPress,
|
||||
}: MarkdownProseProps) {
|
||||
const actions = useAssistantFileLinkActions();
|
||||
const canResolveCode = useStableEvent((content: string) =>
|
||||
actions.canResolveFile({ href: content, text: content, sourceType: "inline-code" }),
|
||||
);
|
||||
const prepare = useMemo(() => createProsePreparer(canResolveCode), [canResolveCode]);
|
||||
const markdown = useMemo(
|
||||
() => prepare(sourceBlocks, text.length),
|
||||
[text.length, sourceBlocks, prepare],
|
||||
);
|
||||
const handleLinkPress = useStableEvent(({ url }: { url: string }) => {
|
||||
handleProseLinkPress(url, (source) => actions.open(source, "preferred"), onLinkPress);
|
||||
});
|
||||
return (
|
||||
<ThemedMarkdown
|
||||
uniProps={kind === "quote" ? quoteStyleMapping : proseStyleMapping}
|
||||
containerStyle={kind === "prose" ? PROSE_CONTAINER : undefined}
|
||||
markdown={markdown}
|
||||
flavor="github"
|
||||
selectable
|
||||
md4cFlags={MD4C_FLAGS}
|
||||
enableTaskListItemToggle={false}
|
||||
enableLinkPreview={false}
|
||||
enableBlockContextMenu={false}
|
||||
allowTrailingMargin
|
||||
streamingAnimation={false}
|
||||
onLinkPress={handleLinkPress}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export function MarkdownProse(props: MarkdownProseProps) {
|
||||
return props.kind === "block" ? props.children : <NativeProse {...props} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { MarkdownProse } from "./renderer.web";
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { MarkdownProseProps } from "./props";
|
||||
|
||||
export function MarkdownProse({ children }: MarkdownProseProps) {
|
||||
return children;
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT } from "@/components/assistant-message-render-limit";
|
||||
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
|
||||
import type Token from "markdown-it/lib/token.mjs";
|
||||
import type { AssistantMessageItem } from "@/types/stream";
|
||||
|
||||
const parser = createAssistantMarkdownParser().set({ html: true });
|
||||
const partsByBlock = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
|
||||
|
||||
function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] {
|
||||
const cached = partsByBlock.get(block);
|
||||
if (cached) return cached;
|
||||
// Oversized blocks cannot join capped runs; the existing renderer caps text before parsing.
|
||||
if (block.text.length > ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) {
|
||||
return [{ ...block, markdownKind: "block" }];
|
||||
}
|
||||
const environment: { references?: unknown } = {};
|
||||
const tokens = parser.parse(block.text, environment);
|
||||
const preserved = tokens.some(
|
||||
(token) =>
|
||||
["fence", "code_block", "table_open", "html_block"].includes(token.type) ||
|
||||
token.children?.some((child) => child.type === "html_inline"),
|
||||
);
|
||||
const isQuote = tokens.some((token) => token.type === "blockquote_open");
|
||||
const hasImage = tokens.some((token) => token.children?.some((child) => child.type === "image"));
|
||||
let parts: AssistantMessageItem[];
|
||||
// Retain the container and its definitions when splitting would lose image context.
|
||||
if (preserved || (hasImage && (environment.references || !canSplitParagraphImages(tokens)))) {
|
||||
parts = [{ ...block, markdownKind: "block" }];
|
||||
} else if (hasImage) {
|
||||
parts = splitParagraphImages(block);
|
||||
} else {
|
||||
parts = [{ ...block, markdownKind: isQuote ? "quote" : "prose" }];
|
||||
}
|
||||
partsByBlock.set(block, parts);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function canSplitParagraphImages(tokens: Token[]): boolean {
|
||||
if (tokens.length !== 3 || tokens[0]?.type !== "paragraph_open") return false;
|
||||
let depth = 0;
|
||||
for (const child of tokens[1]!.children ?? []) {
|
||||
if (child.type === "image" && depth !== 0) return false;
|
||||
depth += child.nesting;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function splitParagraphImages(block: AssistantMessageItem): AssistantMessageItem[] {
|
||||
const parts: AssistantMessageItem[] = [];
|
||||
const state = new parser.inline.State(block.text, parser, {}, []);
|
||||
let cursor = 0;
|
||||
function append(text: string, markdownKind: "prose" | "block", offset: number) {
|
||||
if (!text.trim()) return;
|
||||
parts.push({ ...block, text: text.trim(), markdownKind, id: `${block.id}:part:${offset}` });
|
||||
}
|
||||
while (state.pos < state.posMax) {
|
||||
const start = state.pos;
|
||||
parser.inline.skipToken(state);
|
||||
const candidate = block.text.slice(start, state.pos);
|
||||
if (!candidate.includes("![")) continue;
|
||||
const inline = parser.parseInline(candidate, {})[0];
|
||||
if (!inline?.children?.some((child) => child.type === "image")) continue;
|
||||
append(block.text.slice(cursor, start), "prose", cursor);
|
||||
append(candidate, "block", start);
|
||||
cursor = state.pos;
|
||||
}
|
||||
append(block.text.slice(cursor), "prose", cursor);
|
||||
if (parts.length === 1) parts[0] = { ...parts[0]!, id: block.id };
|
||||
return parts;
|
||||
}
|
||||
|
||||
interface GroupedRuns {
|
||||
growing: boolean;
|
||||
rows: AssistantMessageItem[];
|
||||
starts: number[];
|
||||
ends: number[];
|
||||
}
|
||||
const runsByBlocks = new WeakMap<readonly AssistantMessageItem[], GroupedRuns>();
|
||||
const growingRunsByBlocks = new WeakMap<readonly AssistantMessageItem[], GroupedRuns>();
|
||||
|
||||
/** Rebuild only the run containing the changed source block; completed runs retain identity. */
|
||||
export function groupMarkdownProse(
|
||||
blocks: readonly AssistantMessageItem[],
|
||||
previousBlocks?: readonly AssistantMessageItem[],
|
||||
growing = false,
|
||||
): AssistantMessageItem[] {
|
||||
const cache = growing ? growingRunsByBlocks : runsByBlocks;
|
||||
const cached = cache.get(blocks);
|
||||
if (cached) return cached.rows;
|
||||
const previous = previousGroups(previousBlocks);
|
||||
const retained = retainedRunCount(blocks, previousBlocks, previous, growing);
|
||||
const start = previous?.starts[retained] ?? 0;
|
||||
const rows = previous?.rows.slice(0, retained) ?? [];
|
||||
const starts = previous?.starts.slice(0, retained) ?? [];
|
||||
const ends = previous?.ends.slice(0, retained) ?? [];
|
||||
let parts: AssistantMessageItem[] = [];
|
||||
let length = 0;
|
||||
let runStart = start;
|
||||
let runEnd = start;
|
||||
function flush() {
|
||||
if (!parts.length) return;
|
||||
const first = parts[0]!;
|
||||
const texts = parts.map((part) => part.text);
|
||||
rows.push({
|
||||
...first,
|
||||
text: texts.join("\n\n"),
|
||||
markdownProseBlocks: first.markdownKind === "block" ? undefined : texts,
|
||||
id: first.markdownKind === "prose" ? first.id.replace(":block:", ":run:") : first.id,
|
||||
});
|
||||
starts.push(runStart);
|
||||
ends.push(runEnd);
|
||||
parts = [];
|
||||
length = 0;
|
||||
}
|
||||
for (let index = start; index < blocks.length; index++) {
|
||||
const block = blocks[index]!;
|
||||
const partsForBlock =
|
||||
growing && index === blocks.length - 1
|
||||
? [{ ...block, markdownKind: "block" as const }]
|
||||
: splitBlock(block);
|
||||
for (const part of partsForBlock) {
|
||||
if (parts.length && !canJoinRun(parts[0]!, part, length)) flush();
|
||||
if (!parts.length) runStart = index;
|
||||
length += (parts.length ? 2 : 0) + part.text.length;
|
||||
parts.push(part);
|
||||
runEnd = index;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
reuseUnchangedRows(blocks, previousBlocks, previous, rows, starts, ends, retained);
|
||||
cache.set(blocks, { rows, starts, ends, growing });
|
||||
return rows;
|
||||
}
|
||||
|
||||
function retainedRunCount(
|
||||
blocks: readonly AssistantMessageItem[],
|
||||
previousBlocks: readonly AssistantMessageItem[] | undefined,
|
||||
previous: GroupedRuns | undefined,
|
||||
growing: boolean,
|
||||
): number {
|
||||
if (!previous || !previousBlocks) return 0;
|
||||
let shared = 0;
|
||||
while (shared < blocks.length && blocks[shared] === previousBlocks[shared]) shared++;
|
||||
if (previous.growing && (!growing || blocks.length !== previousBlocks.length))
|
||||
shared = Math.min(shared, previousBlocks.length - 1);
|
||||
let retained = 0;
|
||||
while (retained < previous.rows.length && previous.ends[retained]! < shared) retained++;
|
||||
// Only completed blocks can merge with the preceding run. The live tail is a
|
||||
// hard boundary, so appending to it never rebuilds completed prose.
|
||||
if (!(growing && shared === blocks.length - 1)) retained = Math.max(0, retained - 1);
|
||||
while (retained > 0 && previous.ends[retained - 1]! >= (previous.starts[retained] ?? shared))
|
||||
retained--;
|
||||
return retained;
|
||||
}
|
||||
function reuseUnchangedRows(
|
||||
blocks: readonly AssistantMessageItem[],
|
||||
previousBlocks: readonly AssistantMessageItem[] | undefined,
|
||||
previous: GroupedRuns | undefined,
|
||||
rows: AssistantMessageItem[],
|
||||
starts: number[],
|
||||
ends: number[],
|
||||
retained: number,
|
||||
) {
|
||||
if (!previous || !previousBlocks) return;
|
||||
for (let index = retained; index < rows.length; index++) {
|
||||
const old = previous.rows[index];
|
||||
const row = rows[index]!;
|
||||
if (old?.id !== row.id || old.text !== row.text || old.markdownKind !== row.markdownKind)
|
||||
continue;
|
||||
const end = ends[index]!;
|
||||
let identical = true;
|
||||
for (let block = starts[index]!; block <= end; block++)
|
||||
if (blocks[block] !== previousBlocks[block]) {
|
||||
identical = false;
|
||||
break;
|
||||
}
|
||||
if (!identical) continue;
|
||||
rows[index] = old;
|
||||
}
|
||||
}
|
||||
|
||||
function canJoinRun(
|
||||
first: AssistantMessageItem,
|
||||
next: AssistantMessageItem,
|
||||
length: number,
|
||||
): boolean {
|
||||
return (
|
||||
first.markdownKind === "prose" &&
|
||||
next.markdownKind === "prose" &&
|
||||
length + 2 + next.text.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
function previousGroups(
|
||||
blocks: readonly AssistantMessageItem[] | undefined,
|
||||
): GroupedRuns | undefined {
|
||||
return blocks && (growingRunsByBlocks.get(blocks) ?? runsByBlocks.get(blocks));
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
|
||||
import type Token from "markdown-it/lib/token.mjs";
|
||||
|
||||
export interface InlineSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
tokens: Token[];
|
||||
insideLink: boolean;
|
||||
}
|
||||
export interface InlineTrace {
|
||||
spans: InlineSpan[];
|
||||
linkifyTexts: string[];
|
||||
}
|
||||
const TRACE = Symbol("prose source positions");
|
||||
|
||||
/** Observe the actual inline rules, including nested labels, without reprinting tokens. */
|
||||
export function createSourceParser() {
|
||||
const parser = createAssistantMarkdownParser();
|
||||
const traces = new WeakMap<Token, InlineTrace>();
|
||||
const inlineTraces = new WeakMap<Token[], InlineTrace>();
|
||||
const parseInline = parser.inline.parse.bind(parser.inline);
|
||||
parser.inline.parse = (source, md, env, output) => {
|
||||
const trace: InlineTrace = { spans: [], linkifyTexts: [] };
|
||||
parseInline(source, md, { ...env, [TRACE]: trace }, output);
|
||||
inlineTraces.set(output, trace);
|
||||
};
|
||||
const rules = parser.inline.ruler.getRules("");
|
||||
parser.inline.ruler.before("text", "prose_source_positions", (state, silent) => {
|
||||
if (silent) return false;
|
||||
for (const rule of rules) {
|
||||
const start = state.pos;
|
||||
const count = state.tokens.length;
|
||||
const trace: InlineTrace = state.env[TRACE];
|
||||
const firstNested = trace.spans.length;
|
||||
if (rule(state, false)) {
|
||||
const tokens = state.tokens.slice(count);
|
||||
if (tokens.some((token) => token.type === "link_open")) {
|
||||
for (let i = firstNested; i < trace.spans.length; i++) trace.spans[i]!.insideLink = true;
|
||||
}
|
||||
trace.spans.push({ start, end: state.pos, tokens, insideLink: false });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
parser.core.ruler.after("inline", "prose_inline_traces", (state) => {
|
||||
for (const token of state.tokens) {
|
||||
if (token.type !== "inline" || !token.children) continue;
|
||||
const trace = inlineTraces.get(token.children)!;
|
||||
let linkDepth = 0;
|
||||
for (const child of token.children) {
|
||||
if (child.type === "link_open") linkDepth++;
|
||||
if (child.type === "link_close") linkDepth--;
|
||||
if (child.type === "text" && linkDepth === 0) trace.linkifyTexts.push(child.content);
|
||||
}
|
||||
traces.set(token, trace);
|
||||
}
|
||||
});
|
||||
return { parser, traces };
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { iosMarkdownTextIsSelectable } from "./markdown-text-selection";
|
||||
|
||||
describe("markdown text selection", () => {
|
||||
it("uses plain text only for iOS table cells", () => {
|
||||
expect({
|
||||
tableCell: iosMarkdownTextIsSelectable("table-cell"),
|
||||
prose: iosMarkdownTextIsSelectable("prose"),
|
||||
}).toEqual({
|
||||
tableCell: false,
|
||||
prose: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
|
||||
export type MarkdownTextSurface = "prose" | "table-cell";
|
||||
|
||||
const MarkdownTextSurfaceContext = createContext<MarkdownTextSurface>("prose");
|
||||
|
||||
export function MarkdownTableCellText({ children }: { children: ReactNode }) {
|
||||
return <MarkdownTextSurfaceContext value="table-cell">{children}</MarkdownTextSurfaceContext>;
|
||||
}
|
||||
|
||||
export function useMarkdownTextSurface(): MarkdownTextSurface {
|
||||
return useContext(MarkdownTextSurfaceContext);
|
||||
}
|
||||
|
||||
export function iosMarkdownTextIsSelectable(surface: MarkdownTextSurface): boolean {
|
||||
return surface !== "table-cell";
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { StyleSheet, type TextStyle } from "react-native";
|
||||
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
|
||||
|
||||
function unistylesStyle(id: string, style: Record<string, unknown>) {
|
||||
return {
|
||||
...style,
|
||||
[`unistyles_${id}`]: { id },
|
||||
};
|
||||
}
|
||||
|
||||
function uiTextViewFlatten(rootStyle: TextStyle, style: TextStyle): Record<string, unknown> {
|
||||
// react-native-uitextview/src/util.ts:8 flattens [rootStyle, style]
|
||||
// before passing the result to its native View-backed components.
|
||||
return { ...(StyleSheet.flatten([rootStyle, style]) as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
function unistylesMetadataKeys(style: Record<string, unknown>) {
|
||||
return Object.keys(style).filter((key) => key.startsWith("unistyles_"));
|
||||
}
|
||||
|
||||
describe("resolvePlainMarkdownTextStyle", () => {
|
||||
it("keeps UITextView from collapsing parent and child Unistyles styles into one native View style object", () => {
|
||||
const merged = uiTextViewFlatten(
|
||||
resolvePlainMarkdownTextStyle(unistylesStyle("paragraph", { color: "#111" })),
|
||||
resolvePlainMarkdownTextStyle(unistylesStyle("text", { fontWeight: "600" })),
|
||||
);
|
||||
|
||||
expect(unistylesMetadataKeys(merged)).toHaveLength(0);
|
||||
expect(merged).toMatchObject({ color: "#111", fontWeight: "600" });
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { StyleSheet, type StyleProp, type TextStyle } from "react-native";
|
||||
|
||||
export function resolvePlainMarkdownTextStyle(style: StyleProp<TextStyle>): TextStyle {
|
||||
return stripUnistylesMetadata(StyleSheet.flatten(style) ?? {});
|
||||
}
|
||||
|
||||
function stripUnistylesMetadata(style: TextStyle): TextStyle {
|
||||
// iOS markdown text goes through react-native-uitextview. That library
|
||||
// inherits text styles by flattening [parentStyle, childStyle] before handing
|
||||
// the result to native View-backed components. If both entries are Unistyles
|
||||
// styles, flattening preserves both `unistyles_*` metadata keys in one object,
|
||||
// and Unistyles correctly warns that the style should have stayed array-shaped.
|
||||
//
|
||||
// `UITextView` is a third-party boundary, not a Unistyles-tracked component in
|
||||
// our ownership model. Resolve the concrete style values before crossing that
|
||||
// boundary and drop only Unistyles' private tracking metadata. This preserves
|
||||
// iOS paragraph/spanning text selection while avoiding the metadata merge.
|
||||
const plainStyle: Record<string, unknown> = { ...style };
|
||||
for (const key of Object.keys(plainStyle)) {
|
||||
if (key.startsWith("unistyles_")) {
|
||||
delete plainStyle[key];
|
||||
}
|
||||
}
|
||||
return plainStyle as TextStyle;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import type { MarkdownCopyInlineTag } from "@/assistant-selection-copy/markup";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
copyTag?: MarkdownCopyInlineTag;
|
||||
children: ReactNode;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
|
||||
// Android's <Text selectable> enables per-text-node selection natively. Each
|
||||
// sibling Text is its own selection scope — drag can't span across siblings
|
||||
// (that requires a single UITextView ancestor and is iOS-only). onPress works
|
||||
// natively here, so links routed through this span stay tappable on Android.
|
||||
export function MarkdownTextSpan({
|
||||
style,
|
||||
children,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
}: MarkdownTextSpanProps) {
|
||||
return (
|
||||
<Text selectable style={style} onPress={onPress} accessibilityRole={accessibilityRole}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
containsImage?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// Paragraph stays a <View>, not a <Text>, for layout fidelity. RN Android's
|
||||
// text engine *does* accept inline View children (TextInlineViewPlaceholderSpan
|
||||
// in ReactBaseTextShadowNode), so this isn't a crash-avoidance choice — but
|
||||
// inline-placeholder spans collapse block-level children (e.g. paragraph
|
||||
// images) into one-character placeholders, which destroys image row layout.
|
||||
// <View> preserves the original block layout; the trade-off is no cross-span
|
||||
// selection on Android (a UITextView-style trick has no Android equivalent).
|
||||
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
|
||||
const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./markdown-text.ios";
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { UITextView } from "react-native-uitextview";
|
||||
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
|
||||
import {
|
||||
iosMarkdownTextIsSelectable,
|
||||
useMarkdownTextSurface,
|
||||
} from "@/components/markdown-text-selection";
|
||||
import type { MarkdownCopyInlineTag } from "@/assistant-selection-copy/markup";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
copyTag?: MarkdownCopyInlineTag;
|
||||
children: ReactNode;
|
||||
// Links route through this span too (see assistant-file-links/link.tsx). A
|
||||
// plain <Text> nested in the paragraph UITextView is dropped, so the link
|
||||
// must be a UITextView span to be visible. onPress is wired onto the leaf
|
||||
// string children here: react-native-uitextview attaches it to the
|
||||
// RNUITextViewChild nodes it builds from string content, which the native tap
|
||||
// recognizer dispatches to. The link's handler reaches these leaf spans via
|
||||
// AssistantLinkPressProvider (see assistant-file-links/link-press-context).
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
|
||||
// Inline span backed by UITextView so iOS gets native word-selection handles.
|
||||
// Used inside MarkdownParagraphView (which is also a UITextView on iOS); the
|
||||
// library's TextAncestorContext hoists these into UITextViewChild nodes so
|
||||
// selection drags can cross sibling spans (e.g. plain text → **bold** → code).
|
||||
export function MarkdownTextSpan({
|
||||
style,
|
||||
children,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
}: MarkdownTextSpanProps) {
|
||||
const plainStyle = useMemo(() => resolvePlainMarkdownTextStyle(style), [style]);
|
||||
const surface = useMarkdownTextSurface();
|
||||
|
||||
// Each selectable span creates a UIKit UITextView with a window-level tap recognizer.
|
||||
// A large table would create one per cell and make every app touch fan out across them.
|
||||
if (!iosMarkdownTextIsSelectable(surface)) {
|
||||
return (
|
||||
<Text
|
||||
selectable={false}
|
||||
style={plainStyle}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UITextView
|
||||
uiTextView
|
||||
selectable
|
||||
style={plainStyle}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
>
|
||||
{children}
|
||||
</UITextView>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
containsImage?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// iOS-only: paragraph wraps in UITextView so the entire paragraph is one
|
||||
// native text view. That's what unlocks cross-inline drag selection — handles
|
||||
// can span every MarkdownTextSpan child inside this paragraph.
|
||||
// ViewStyle is structurally compatible with the layout props paragraphs use
|
||||
// (margin, padding, alignment); the cast lets the existing paragraphStyle
|
||||
// flow through unchanged.
|
||||
export function MarkdownParagraphView({
|
||||
paragraphStyle,
|
||||
containsImage = false,
|
||||
children,
|
||||
}: MarkdownParagraphViewProps) {
|
||||
const textStyle = useMemo(
|
||||
() =>
|
||||
resolvePlainMarkdownTextStyle([
|
||||
paragraphStyle,
|
||||
MARKDOWN_PARAGRAPH_RESET,
|
||||
] as StyleProp<TextStyle>),
|
||||
[paragraphStyle],
|
||||
);
|
||||
const viewStyle = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
|
||||
|
||||
if (containsImage) {
|
||||
return <View style={viewStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<UITextView uiTextView selectable style={textStyle}>
|
||||
{children}
|
||||
</UITextView>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { markdownCopyDataSet, type MarkdownCopyInlineTag } from "@/assistant-selection-copy/markup";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
copyTag?: MarkdownCopyInlineTag;
|
||||
children: ReactNode;
|
||||
// Web links use the <a>/Pressable path in link.tsx, not this span, so these
|
||||
// are accepted for prop-shape parity with the native variants and forwarded
|
||||
// harmlessly.
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
|
||||
// react-native-web renders Text as <span>/<div> with `user-select: text`
|
||||
// already applied via markdownStyleMapping. The web bundle must not import
|
||||
// react-native-uitextview: its transitive import of codegenNativeComponent
|
||||
// pulls in setUpReactDevTools, which doesn't resolve under Metro's web
|
||||
// target in dev mode.
|
||||
export function MarkdownTextSpan({
|
||||
style,
|
||||
monoSurface,
|
||||
copyTag,
|
||||
children,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
}: MarkdownTextSpanProps) {
|
||||
const dataSet = useMemo(() => {
|
||||
if (copyTag && (monoSurface || copyTag === "code")) {
|
||||
return { ...CODE_SURFACE_DATASET, ...markdownCopyDataSet[copyTag] };
|
||||
}
|
||||
if (copyTag) {
|
||||
return markdownCopyDataSet[copyTag];
|
||||
}
|
||||
return monoSurface ? CODE_SURFACE_DATASET : undefined;
|
||||
}, [copyTag, monoSurface]);
|
||||
|
||||
return (
|
||||
<Text dataSet={dataSet} style={style} onPress={onPress} accessibilityRole={accessibilityRole}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
containsImage?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// Same shape as Android — paragraph is a View so block-level children (images)
|
||||
// keep their natural layout. Web text selection already spans nested inline
|
||||
// elements via CSS user-select, so no UITextView equivalent is needed.
|
||||
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
|
||||
const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
|
||||
return (
|
||||
<View style={style} dataSet={markdownCopyDataSet.p}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { getMarkdownFenceLanguage } from "./language";
|
||||
import { MermaidFence } from "./mermaid";
|
||||
import type { MarkdownFenceRendererProps } from "./types";
|
||||
|
||||
export interface MarkdownFenceBlockProps extends MarkdownFenceRendererProps {
|
||||
info: string | null | undefined;
|
||||
}
|
||||
|
||||
const diagramFences: Partial<Record<string, ComponentType<MarkdownFenceRendererProps>>> = {
|
||||
mermaid: MermaidFence,
|
||||
};
|
||||
|
||||
export function MarkdownFenceBlock({
|
||||
code,
|
||||
info,
|
||||
phase,
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
}: MarkdownFenceBlockProps) {
|
||||
const language = getMarkdownFenceLanguage(info);
|
||||
const DiagramFence = language ? diagramFences[language] : undefined;
|
||||
if (DiagramFence) {
|
||||
return (
|
||||
<DiagramFence
|
||||
code={code}
|
||||
phase={phase}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={textStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language={language}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={textStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMarkdownFenceLanguage } from "./language";
|
||||
|
||||
describe("getMarkdownFenceLanguage", () => {
|
||||
it("normalizes the first info-string token", () => {
|
||||
expect(getMarkdownFenceLanguage(" Sirena theme=dark ")).toBe("sirena");
|
||||
expect(getMarkdownFenceLanguage("TypeScript title=example")).toBe("typescript");
|
||||
});
|
||||
|
||||
it("returns null when no language is declared", () => {
|
||||
expect(getMarkdownFenceLanguage("")).toBeNull();
|
||||
expect(getMarkdownFenceLanguage(null)).toBeNull();
|
||||
expect(getMarkdownFenceLanguage(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export function getMarkdownFenceLanguage(info: string | null | undefined): string | null {
|
||||
return info?.trim().split(/\s+/)[0]?.toLowerCase() || null;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import esbuild from "esbuild";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "../../../../..");
|
||||
const repoRoot = path.resolve(appRoot, "../..");
|
||||
const entry = path.join(__dirname, "runtime/entry.ts");
|
||||
const output = path.join(__dirname, "runtime/html.gen.ts");
|
||||
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["ios15", "chrome100"],
|
||||
minify: true,
|
||||
// The app's deployment floor is iOS 15.1 but structuredClone (used by
|
||||
// mermaid) only exists from iOS 15.4; esbuild targets transform syntax,
|
||||
// not runtime APIs. Mermaid only clones plain config objects, so a
|
||||
// JSON-based fallback is sufficient.
|
||||
banner: {
|
||||
js: 'if(typeof globalThis.structuredClone!=="function"){globalThis.structuredClone=function(v){return v===undefined?v:JSON.parse(JSON.stringify(v));};}',
|
||||
},
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
const rawJs = result.outputFiles[0]?.text;
|
||||
if (!rawJs) {
|
||||
throw new Error("Mermaid runtime bundle produced no JavaScript");
|
||||
}
|
||||
// JSON.stringify below escapes the TypeScript representation, not the runtime
|
||||
// HTML — an embedded closing-script sequence would still terminate the inline
|
||||
// <script>. `<\/script` is identical to `</script` inside JS strings/regex.
|
||||
const js = rawJs.replace(/<\/script/gi, "<\\/script");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; img-src data: blob:; font-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
}
|
||||
/* The host sizes this frame to the diagram, so the diagram fills the frame. */
|
||||
#diagram {
|
||||
height: 100%;
|
||||
}
|
||||
#diagram svg {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="diagram"></div>
|
||||
<script>${js}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const contents = `// Generated by packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs.
|
||||
// Do not edit by hand.
|
||||
|
||||
export const mermaidRuntimeHtml = ${JSON.stringify(html)};
|
||||
`;
|
||||
|
||||
await fs.writeFile(output, contents);
|
||||
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Modal, View } from "react-native";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ZoomableViewport } from "@/components/zoomable-viewport";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
|
||||
import type { DiagramColorScheme } from "./render-model";
|
||||
import { useMermaidRenderModel } from "./use-render-model";
|
||||
|
||||
interface MermaidFullscreenViewerProps {
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// The diagram is already in the render cache, so the viewport knows its size before the
|
||||
// fullscreen iframe has re-rendered it.
|
||||
const FALLBACK_SIZE = { width: 240, height: 240 };
|
||||
const VIEWER_FIT = { padding: SPACING[6] };
|
||||
|
||||
export function MermaidFullscreenViewer({
|
||||
source,
|
||||
colorScheme,
|
||||
onClose,
|
||||
}: MermaidFullscreenViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
|
||||
source,
|
||||
phase: "complete",
|
||||
colorScheme,
|
||||
});
|
||||
const handleRendered = useCallback(
|
||||
(message: MermaidRenderedMessage) => {
|
||||
rendered({
|
||||
revision: message.revision,
|
||||
source: message.source,
|
||||
colorScheme: message.colorScheme,
|
||||
dimensions: { height: message.height, width: message.width },
|
||||
});
|
||||
},
|
||||
[rendered],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, [onClose]);
|
||||
|
||||
// The Modal focus trap walks descendants and focuses the first one that takes it. Focus the
|
||||
// content layer ourselves (this effect runs after the trap's) so keystrokes stay in this
|
||||
// document instead of disappearing into a focused diagram iframe.
|
||||
const contentLayerRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
contentLayerRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const visible = state.visible;
|
||||
const contentSize = useMemo(
|
||||
() => (visible ? { width: visible.width, height: visible.height } : FALLBACK_SIZE),
|
||||
[visible],
|
||||
);
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: X,
|
||||
label: t("common.actions.close"),
|
||||
onPress: onClose,
|
||||
testID: "mermaid-fullscreen-close",
|
||||
},
|
||||
],
|
||||
[onClose, t],
|
||||
);
|
||||
const contentLayerStyle = useMemo(
|
||||
() => [
|
||||
styles.contentLayer,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
},
|
||||
],
|
||||
[insets.bottom, insets.left, insets.right, insets.top],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View style={styles.root}>
|
||||
<View style={styles.backdrop} />
|
||||
<View style={contentLayerStyle}>
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focus holder for the Escape shortcut */}
|
||||
<div ref={contentLayerRef} style={focusLayerDomStyle} tabIndex={-1}>
|
||||
<ZoomableViewport
|
||||
accessibilityLabel={t("message.diagram.diagram")}
|
||||
actions={actions}
|
||||
contentSize={contentSize}
|
||||
fit={VIEWER_FIT}
|
||||
minScale={1}
|
||||
onPressOutsideContent={onClose}
|
||||
style={styles.viewport}
|
||||
testID="mermaid-fullscreen-viewport"
|
||||
wheelActivation="always"
|
||||
>
|
||||
<MermaidIframeRuntime
|
||||
request={request}
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
</ZoomableViewport>
|
||||
</div>
|
||||
</View>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const focusLayerDomStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flex: 1, minHeight: 0, minWidth: 0 },
|
||||
backdrop: { position: "absolute", inset: 0, backgroundColor: theme.colors.surface0 },
|
||||
contentLayer: { position: "absolute", inset: 0 },
|
||||
viewport: { flex: 1 },
|
||||
}));
|
||||
@@ -1,379 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet as RNStyleSheet,
|
||||
ScrollView,
|
||||
View,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { WebView, type WebViewMessageEvent } from "react-native-webview";
|
||||
import { Code, Workflow, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { MarkdownFenceRendererProps } from "../types";
|
||||
import type { MermaidRenderRequest } from "./render-model";
|
||||
import { mermaidRuntimeHtml } from "./runtime/html.gen";
|
||||
import {
|
||||
parseMermaidRuntimeMessage,
|
||||
serializeMermaidRuntimeRenderMessage,
|
||||
type MermaidRuntimeRenderMessage,
|
||||
} from "./runtime/messages";
|
||||
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
|
||||
import { useMermaidRenderModel } from "./use-render-model";
|
||||
import { getDiagramBoxStyle } from "./presentation";
|
||||
|
||||
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
|
||||
colorScheme?: "light" | "dark";
|
||||
}
|
||||
|
||||
const WEBVIEW_SOURCE = { html: mermaidRuntimeHtml };
|
||||
const WEBVIEW_ORIGIN_WHITELIST = ["*"];
|
||||
const MAX_PREVIEW_HEIGHT = 480;
|
||||
|
||||
interface MermaidWebViewProps {
|
||||
request: MermaidRenderRequest | null;
|
||||
interactive: boolean;
|
||||
onRendered: (message: {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: "light" | "dark";
|
||||
height: number;
|
||||
width: number;
|
||||
}) => void;
|
||||
onRenderFailed: (revision: number) => void;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function MermaidWebView({
|
||||
request,
|
||||
interactive,
|
||||
onRendered,
|
||||
onRenderFailed,
|
||||
style,
|
||||
}: MermaidWebViewProps) {
|
||||
const webViewRef = useRef<WebView | null>(null);
|
||||
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
|
||||
driverRef.current ??= new MermaidRuntimeRequestDriver();
|
||||
|
||||
const sendRequest = useCallback(
|
||||
(current: MermaidRenderRequest | null) => {
|
||||
if (!current || !webViewRef.current) {
|
||||
return;
|
||||
}
|
||||
const message: MermaidRuntimeRenderMessage = {
|
||||
type: "render",
|
||||
revision: current.revision,
|
||||
source: current.source,
|
||||
colorScheme: current.colorScheme,
|
||||
interactive,
|
||||
};
|
||||
const payload = serializeMermaidRuntimeRenderMessage(message);
|
||||
webViewRef.current.injectJavaScript(
|
||||
`window.__PASEO_MERMAID_RUNTIME_RECEIVE__ && window.__PASEO_MERMAID_RUNTIME_RECEIVE__(${payload}); true;`,
|
||||
);
|
||||
},
|
||||
[interactive],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
sendRequest(driverRef.current?.update(request) ?? null);
|
||||
}, [request, sendRequest]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(event.nativeEvent.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const message = parseMermaidRuntimeMessage(value);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "bridgeReady") {
|
||||
sendRequest(driverRef.current?.ready() ?? null);
|
||||
return;
|
||||
}
|
||||
if (message.type === "renderError") {
|
||||
onRenderFailed(message.revision);
|
||||
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
|
||||
return;
|
||||
}
|
||||
onRendered(message);
|
||||
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
|
||||
},
|
||||
[onRenderFailed, onRendered, sendRequest],
|
||||
);
|
||||
const handleShouldStartLoad = useCallback(
|
||||
(load: { url: string }) => load.url === "about:blank" || load.url.startsWith("data:"),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={WEBVIEW_SOURCE}
|
||||
originWhitelist={WEBVIEW_ORIGIN_WHITELIST}
|
||||
style={[webViewStyles.webView, style]}
|
||||
onMessage={handleMessage}
|
||||
onShouldStartLoadWithRequest={handleShouldStartLoad}
|
||||
scrollEnabled={interactive}
|
||||
bounces={false}
|
||||
scalesPageToFit={interactive}
|
||||
setSupportMultipleWindows={false}
|
||||
allowsLinkPreview={false}
|
||||
javaScriptEnabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const webViewStyles = RNStyleSheet.create({
|
||||
webView: { backgroundColor: "transparent" },
|
||||
});
|
||||
|
||||
interface MermaidDiagramViewerProps {
|
||||
code: string;
|
||||
colorScheme: "light" | "dark";
|
||||
onClose: () => void;
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
}
|
||||
|
||||
function MermaidDiagramViewer({
|
||||
code,
|
||||
colorScheme,
|
||||
onClose,
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
}: MermaidDiagramViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const toggleSource = useCallback(() => setShowSource((current) => !current), []);
|
||||
const { request, rendered, renderFailed } = useMermaidRenderModel({
|
||||
source: code,
|
||||
phase: "complete",
|
||||
colorScheme,
|
||||
});
|
||||
const handleRendered = useCallback(
|
||||
(message: {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: "light" | "dark";
|
||||
height: number;
|
||||
width: number;
|
||||
}) => {
|
||||
rendered({
|
||||
revision: message.revision,
|
||||
source: message.source,
|
||||
colorScheme: message.colorScheme,
|
||||
dimensions: { height: message.height, width: message.width },
|
||||
});
|
||||
},
|
||||
[rendered],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<View style={viewerStyles.backdrop}>
|
||||
<View style={viewerStyles.webView}>
|
||||
<MermaidWebView
|
||||
request={request}
|
||||
interactive
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
style={viewerStyles.runtime}
|
||||
/>
|
||||
{showSource ? (
|
||||
<ScrollView
|
||||
style={viewerStyles.sourceOverlay}
|
||||
contentContainerStyle={viewerStyles.source}
|
||||
>
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={textStyle}
|
||||
/>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={viewerStyles.actions}>
|
||||
<Pressable
|
||||
onPress={toggleSource}
|
||||
style={viewerStyles.actionButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
showSource ? t("message.diagram.viewDiagram") : t("message.diagram.viewSource")
|
||||
}
|
||||
hitSlop={12}
|
||||
>
|
||||
{showSource ? (
|
||||
<ThemedDiagramIcon size={20} uniProps={closeIconColor} />
|
||||
) : (
|
||||
<ThemedSourceIcon size={20} uniProps={closeIconColor} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={viewerStyles.actionButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("common.actions.close")}
|
||||
hitSlop={12}
|
||||
>
|
||||
<ThemedCloseIcon size={20} uniProps={closeIconColor} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedCloseIcon = withUnistyles(X);
|
||||
const ThemedSourceIcon = withUnistyles(Code);
|
||||
const ThemedDiagramIcon = withUnistyles(Workflow);
|
||||
const closeIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
const viewerStyles = StyleSheet.create((theme, runtime) => ({
|
||||
backdrop: { flex: 1, backgroundColor: theme.colors.surface0 },
|
||||
webView: {
|
||||
flex: 1,
|
||||
marginTop: runtime.insets.top,
|
||||
marginBottom: runtime.insets.bottom,
|
||||
},
|
||||
runtime: { flex: 1 },
|
||||
sourceOverlay: {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
source: {
|
||||
paddingTop: theme.spacing[12],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
actions: {
|
||||
position: "absolute",
|
||||
top: runtime.insets.top + theme.spacing[3],
|
||||
right: theme.spacing[4],
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actionButton: {
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
function MermaidFenceHostImpl({
|
||||
code,
|
||||
phase,
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
colorScheme = "dark",
|
||||
}: MermaidFenceHostImplProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
|
||||
source: code,
|
||||
phase,
|
||||
colorScheme,
|
||||
});
|
||||
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
|
||||
const handleRendered = useCallback(
|
||||
(message: {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: "light" | "dark";
|
||||
height: number;
|
||||
width: number;
|
||||
}) => {
|
||||
setHasRuntimeContent(true);
|
||||
rendered({
|
||||
revision: message.revision,
|
||||
source: message.source,
|
||||
colorScheme: message.colorScheme,
|
||||
dimensions: { height: message.height, width: message.width },
|
||||
});
|
||||
},
|
||||
[rendered],
|
||||
);
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const openViewer = useCallback(() => setViewerOpen(true), []);
|
||||
const closeViewer = useCallback(() => setViewerOpen(false), []);
|
||||
const visible = state.visible;
|
||||
const canShowDiagram = visible !== null && hasRuntimeContent;
|
||||
const previewInnerStyle = useMemo(
|
||||
() =>
|
||||
canShowDiagram && visible
|
||||
? { height: Math.min(visible.height, MAX_PREVIEW_HEIGHT) }
|
||||
: previewStyles.measuringInner,
|
||||
[canShowDiagram, visible],
|
||||
);
|
||||
const diagramBoxStyle = getDiagramBoxStyle(textStyle);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!canShowDiagram ? (
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={textStyle}
|
||||
/>
|
||||
) : null}
|
||||
<Pressable
|
||||
onPress={openViewer}
|
||||
disabled={!canShowDiagram}
|
||||
accessibilityRole={canShowDiagram ? "imagebutton" : undefined}
|
||||
accessibilityLabel={t("message.diagram.diagram")}
|
||||
style={
|
||||
canShowDiagram
|
||||
? [diagramBoxStyle, previewStyles.preview]
|
||||
: [diagramBoxStyle, previewStyles.measuring]
|
||||
}
|
||||
>
|
||||
<View style={previewInnerStyle} pointerEvents="none">
|
||||
<MermaidWebView
|
||||
request={request}
|
||||
interactive={false}
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
{viewerOpen && canShowDiagram && visible ? (
|
||||
<MermaidDiagramViewer
|
||||
code={visible.source}
|
||||
colorScheme={visible.colorScheme}
|
||||
onClose={closeViewer}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={textStyle}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const previewStyles = RNStyleSheet.create({
|
||||
measuring: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
measuringInner: { height: 240 },
|
||||
preview: { overflow: "hidden" },
|
||||
});
|
||||
|
||||
const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
|
||||
const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
|
||||
|
||||
export function MermaidFenceHost(props: MarkdownFenceRendererProps) {
|
||||
return <ThemedMermaidFenceHost {...props} uniProps={mapColorScheme} />;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import type { MarkdownFenceRendererProps } from "../types";
|
||||
|
||||
export function MermaidFenceHost({ code, inheritedStyles, textStyle }: MarkdownFenceRendererProps) {
|
||||
return (
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={textStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { Code, Maximize2, Workflow } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { ZoomableViewport } from "@/components/zoomable-viewport";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { MarkdownFenceRendererProps } from "../types";
|
||||
import { MermaidFullscreenViewer } from "./fullscreen-viewer.web";
|
||||
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
|
||||
import { useMermaidRenderModel } from "./use-render-model";
|
||||
import { getDiagramBoxStyle } from "./presentation";
|
||||
|
||||
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
|
||||
colorScheme?: "light" | "dark";
|
||||
}
|
||||
|
||||
function MermaidFenceHostImpl({
|
||||
code,
|
||||
phase,
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
colorScheme = "dark",
|
||||
}: MermaidFenceHostImplProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
|
||||
source: code,
|
||||
phase,
|
||||
colorScheme,
|
||||
});
|
||||
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const showSourcePress = useCallback(() => setShowSource(true), []);
|
||||
const showDiagramPress = useCallback(() => setShowSource(false), []);
|
||||
const openFullscreen = useCallback(() => setIsFullscreen(true), []);
|
||||
const closeFullscreen = useCallback(() => setIsFullscreen(false), []);
|
||||
const handleRendered = useCallback(
|
||||
(message: MermaidRenderedMessage) => {
|
||||
setHasRuntimeContent(true);
|
||||
rendered({
|
||||
revision: message.revision,
|
||||
source: message.source,
|
||||
colorScheme: message.colorScheme,
|
||||
dimensions: { height: message.height, width: message.width },
|
||||
});
|
||||
},
|
||||
[rendered],
|
||||
);
|
||||
const visible = state.visible;
|
||||
const canShowDiagram = visible !== null && hasRuntimeContent;
|
||||
const diagramVisible = canShowDiagram && !showSource;
|
||||
const runtimeHeight = Math.max(visible?.height ?? 240, 1);
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: Code,
|
||||
label: t("message.diagram.viewSource"),
|
||||
onPress: showSourcePress,
|
||||
},
|
||||
{
|
||||
icon: Maximize2,
|
||||
label: t("message.diagram.fullscreen"),
|
||||
onPress: openFullscreen,
|
||||
testID: "mermaid-fullscreen",
|
||||
},
|
||||
],
|
||||
[openFullscreen, showSourcePress, t],
|
||||
);
|
||||
const sourceView = useMemo(() => {
|
||||
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
|
||||
const margins: ViewStyle = {
|
||||
marginTop: marginTop ?? marginVertical,
|
||||
marginBottom: marginBottom ?? marginVertical,
|
||||
};
|
||||
const text: TextStyle = sourceTextStyle;
|
||||
return { container: [margins, sourceContainerStyle], text };
|
||||
}, [textStyle]);
|
||||
const diagramStyle = useMemo(
|
||||
() => [
|
||||
getDiagramBoxStyle(textStyle),
|
||||
containerStyle,
|
||||
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
|
||||
],
|
||||
[runtimeHeight, textStyle],
|
||||
);
|
||||
const diagramSize = useMemo(
|
||||
() => (visible ? { width: visible.width, height: visible.height } : MEASURING_SIZE),
|
||||
[visible],
|
||||
);
|
||||
const sourceVisible = !diagramVisible;
|
||||
const sourceContainer = showSource ? sourceView.container : sourceContainerStyle;
|
||||
const sourceTextStyle = showSource ? sourceView.text : textStyle;
|
||||
const viewportStyle = diagramVisible ? diagramStyle : measuringStyle;
|
||||
|
||||
return (
|
||||
<>
|
||||
{sourceVisible ? (
|
||||
<View style={sourceContainer}>
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={sourceTextStyle}
|
||||
/>
|
||||
{showSource && canShowDiagram ? (
|
||||
<Pressable
|
||||
accessibilityLabel={t("message.diagram.viewDiagram")}
|
||||
accessibilityRole="button"
|
||||
hitSlop={4}
|
||||
onPress={showDiagramPress}
|
||||
style={controlStyles.sourceButton}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Workflow
|
||||
size={14}
|
||||
color={hovered ? controlStyles.iconHovered.color : controlStyles.icon.color}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<ZoomableViewport
|
||||
accessibilityLabel={t("message.diagram.diagram")}
|
||||
actions={actions}
|
||||
contentSize={diagramSize}
|
||||
style={viewportStyle}
|
||||
testID="mermaid-viewport"
|
||||
wheelActivation="modifier"
|
||||
>
|
||||
<MermaidIframeRuntime
|
||||
request={request}
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
</ZoomableViewport>
|
||||
{isFullscreen && visible ? (
|
||||
<MermaidFullscreenViewer
|
||||
source={visible.source}
|
||||
colorScheme={visible.colorScheme}
|
||||
onClose={closeFullscreen}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const MEASURING_SIZE = { width: 240, height: 240 };
|
||||
/**
|
||||
* The toolbar overlays the top of the box (8px offset + 32px compact buttons) and the box clips
|
||||
* with `overflow: hidden`, so a shorter box leaves the buttons half-clipped and unclickable.
|
||||
*/
|
||||
const MIN_DIAGRAM_BOX_HEIGHT = 56;
|
||||
const sourceContainerStyle: ViewStyle = { position: "relative" };
|
||||
/**
|
||||
* The viewport's own root is `flex: 1`, so as a flex item it has `flex-basis: 0%` and that basis
|
||||
* replaces the height below. In the markdown column there is no free space to grow into, so the
|
||||
* box collapses and the diagram is scaled down to fit a couple of pixels. Size it from the height.
|
||||
*/
|
||||
const containerStyle: ViewStyle = {
|
||||
flexBasis: "auto",
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
};
|
||||
const measuringStyle: ViewStyle = {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: 240,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const controlStyles = StyleSheet.create((theme) => ({
|
||||
sourceButton: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
padding: theme.spacing[1],
|
||||
},
|
||||
icon: { color: theme.colors.foregroundMuted },
|
||||
iconHovered: { color: theme.colors.foreground },
|
||||
}));
|
||||
const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
|
||||
const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
|
||||
|
||||
export function MermaidFenceHost(props: MarkdownFenceRendererProps) {
|
||||
return <ThemedMermaidFenceHost {...props} uniProps={mapColorScheme} />;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { DiagramColorScheme, MermaidRenderRequest } from "./render-model";
|
||||
import { mermaidRuntimeHtml } from "./runtime/html.gen";
|
||||
import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages";
|
||||
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
|
||||
|
||||
export interface MermaidRenderedMessage {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface MermaidIframeRuntimeProps {
|
||||
request: MermaidRenderRequest | null;
|
||||
onRendered: (message: MermaidRenderedMessage) => void;
|
||||
onRenderFailed: (revision: number) => void;
|
||||
}
|
||||
|
||||
/** Sandboxed Mermaid renderer. Sizing and gestures belong to the surrounding viewport. */
|
||||
export function MermaidIframeRuntime({
|
||||
request,
|
||||
onRendered,
|
||||
onRenderFailed,
|
||||
}: MermaidIframeRuntimeProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
|
||||
driverRef.current ??= new MermaidRuntimeRequestDriver();
|
||||
|
||||
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
|
||||
const target = iframeRef.current?.contentWindow;
|
||||
if (!current || !target) return;
|
||||
const message: MermaidRuntimeRenderMessage = {
|
||||
type: "render",
|
||||
revision: current.revision,
|
||||
source: current.source,
|
||||
colorScheme: current.colorScheme,
|
||||
interactive: false,
|
||||
};
|
||||
target.postMessage(message, "*");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
sendRequest(driverRef.current?.update(request) ?? null);
|
||||
}, [request, sendRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
function receiveMessage(event: MessageEvent): void {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
const message = parseMermaidRuntimeMessage(event.data);
|
||||
if (!message) return;
|
||||
if (message.type === "bridgeReady") {
|
||||
sendRequest(driverRef.current?.ready() ?? null);
|
||||
return;
|
||||
}
|
||||
if (message.type === "renderError") {
|
||||
onRenderFailed(message.revision);
|
||||
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
|
||||
return;
|
||||
}
|
||||
onRendered(message);
|
||||
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
|
||||
}
|
||||
window.addEventListener("message", receiveMessage);
|
||||
return () => window.removeEventListener("message", receiveMessage);
|
||||
}, [onRenderFailed, onRendered, sendRequest]);
|
||||
|
||||
// `inert` (not just tabIndex) because the Modal focus trap focuses descendants
|
||||
// programmatically; a focused iframe swallows every keystroke, including Escape.
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title=""
|
||||
aria-hidden
|
||||
inert
|
||||
sandbox="allow-scripts"
|
||||
srcDoc={mermaidRuntimeHtml}
|
||||
tabIndex={-1}
|
||||
style={iframeStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const iframeStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: 0,
|
||||
pointerEvents: "none",
|
||||
background: "transparent",
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { MarkdownFenceRendererProps } from "../types";
|
||||
import { MermaidFenceHost } from "./host";
|
||||
|
||||
export function MermaidFence(props: MarkdownFenceRendererProps) {
|
||||
return <MermaidFenceHost {...props} />;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { TextStyle, ViewStyle } from "react-native";
|
||||
|
||||
export function getDiagramBoxStyle(style: TextStyle): ViewStyle {
|
||||
return {
|
||||
backgroundColor: style.backgroundColor,
|
||||
borderColor: style.borderColor,
|
||||
borderRadius: style.borderRadius,
|
||||
borderWidth: style.borderWidth,
|
||||
marginBottom: style.marginBottom,
|
||||
marginTop: style.marginTop,
|
||||
marginVertical: style.marginVertical,
|
||||
padding: style.padding,
|
||||
};
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMermaidRenderModel,
|
||||
getMermaidRenderRequest,
|
||||
reduceMermaidRenderModel,
|
||||
type MermaidRenderInput,
|
||||
type MermaidRenderModel,
|
||||
} from "./render-model";
|
||||
|
||||
function input(overrides: Partial<MermaidRenderInput> = {}): MermaidRenderInput {
|
||||
return {
|
||||
source: "flowchart TD\nA --> B",
|
||||
phase: "streaming",
|
||||
colorScheme: "dark",
|
||||
rejected: false,
|
||||
cached: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderCurrent(state: MermaidRenderModel): MermaidRenderModel {
|
||||
return reduceMermaidRenderModel(state, {
|
||||
type: "rendered",
|
||||
revision: state.revision,
|
||||
source: state.source,
|
||||
colorScheme: state.colorScheme,
|
||||
dimensions: { height: 120, width: 240 },
|
||||
});
|
||||
}
|
||||
|
||||
describe("Mermaid render model", () => {
|
||||
it("shows each valid streamed revision", () => {
|
||||
const first = renderCurrent(createMermaidRenderModel(input()));
|
||||
const secondPending = reduceMermaidRenderModel(first, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --> B\nB --> C" }),
|
||||
});
|
||||
const second = renderCurrent(secondPending);
|
||||
|
||||
expect(first.visible?.source).toBe("flowchart TD\nA --> B");
|
||||
expect(second.visible?.source).toBe("flowchart TD\nA --> B\nB --> C");
|
||||
});
|
||||
|
||||
it("keeps the last valid preview across a transient streaming failure", () => {
|
||||
const rendered = renderCurrent(createMermaidRenderModel(input()));
|
||||
const pending = reduceMermaidRenderModel(rendered, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --" }),
|
||||
});
|
||||
const failed = reduceMermaidRenderModel(pending, {
|
||||
type: "renderFailed",
|
||||
revision: pending.revision,
|
||||
});
|
||||
|
||||
expect(failed.status).toBe("failed");
|
||||
expect(failed.visible?.source).toBe("flowchart TD\nA --> B");
|
||||
});
|
||||
|
||||
it("clears a stale preview when failed source becomes final", () => {
|
||||
const rendered = renderCurrent(createMermaidRenderModel(input()));
|
||||
const pending = reduceMermaidRenderModel(rendered, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --" }),
|
||||
});
|
||||
const failed = reduceMermaidRenderModel(pending, {
|
||||
type: "renderFailed",
|
||||
revision: pending.revision,
|
||||
});
|
||||
const complete = reduceMermaidRenderModel(failed, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --", phase: "complete" }),
|
||||
});
|
||||
|
||||
expect(complete.status).toBe("failed");
|
||||
expect(complete.visible).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the completion phase when an in-flight final render fails", () => {
|
||||
const rendered = renderCurrent(createMermaidRenderModel(input()));
|
||||
const pending = reduceMermaidRenderModel(rendered, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --" }),
|
||||
});
|
||||
const completePending = reduceMermaidRenderModel(pending, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --", phase: "complete" }),
|
||||
});
|
||||
const failed = reduceMermaidRenderModel(completePending, {
|
||||
type: "renderFailed",
|
||||
revision: completePending.revision,
|
||||
});
|
||||
|
||||
expect(failed.visible).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores obsolete render results", () => {
|
||||
const initial = createMermaidRenderModel(input());
|
||||
const newer = reduceMermaidRenderModel(initial, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --> C" }),
|
||||
});
|
||||
const obsolete = reduceMermaidRenderModel(newer, {
|
||||
type: "rendered",
|
||||
revision: initial.revision,
|
||||
source: initial.source,
|
||||
colorScheme: initial.colorScheme,
|
||||
dimensions: { height: 90, width: 180 },
|
||||
});
|
||||
|
||||
expect(obsolete).toBe(newer);
|
||||
expect(getMermaidRenderRequest(obsolete)).toEqual({
|
||||
revision: newer.revision,
|
||||
source: "flowchart TD\nA --> C",
|
||||
colorScheme: "dark",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an obsolete successful prefix as the first streaming preview", () => {
|
||||
const initial = createMermaidRenderModel(input());
|
||||
const newer = reduceMermaidRenderModel(initial, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --> B\nB --" }),
|
||||
});
|
||||
const preview = reduceMermaidRenderModel(newer, {
|
||||
type: "rendered",
|
||||
revision: initial.revision,
|
||||
source: initial.source,
|
||||
colorScheme: initial.colorScheme,
|
||||
dimensions: { height: 90, width: 180 },
|
||||
});
|
||||
|
||||
expect(preview.status).toBe("pending");
|
||||
expect(preview.visible?.source).toBe(initial.source);
|
||||
expect(getMermaidRenderRequest(preview)?.source).toBe("flowchart TD\nA --> B\nB --");
|
||||
});
|
||||
|
||||
it("keeps the last preview when source policy rejects a streaming revision", () => {
|
||||
const rendered = renderCurrent(createMermaidRenderModel(input()));
|
||||
const rejected = reduceMermaidRenderModel(rendered, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: 'flowchart TD\nA@{ img: "https://example.com/x" }', rejected: true }),
|
||||
});
|
||||
|
||||
expect(rejected.status).toBe("rejected");
|
||||
expect(rejected.visible).toBe(rendered.visible);
|
||||
expect(getMermaidRenderRequest(rejected)).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the last preview when the rejected revision is complete", () => {
|
||||
const rendered = renderCurrent(createMermaidRenderModel(input()));
|
||||
const rejected = reduceMermaidRenderModel(rendered, {
|
||||
type: "inputChanged",
|
||||
input: input({
|
||||
source: 'flowchart TD\nA@{ img: "https://example.com/x" }',
|
||||
phase: "complete",
|
||||
rejected: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(rejected.status).toBe("rejected");
|
||||
expect(rejected.visible).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import type { MarkdownPhase } from "../types";
|
||||
|
||||
export type DiagramColorScheme = "light" | "dark";
|
||||
|
||||
export interface DiagramDimensions {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface RenderedDiagram extends DiagramDimensions {
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
}
|
||||
|
||||
export interface MermaidRenderInput {
|
||||
source: string;
|
||||
phase: MarkdownPhase;
|
||||
colorScheme: DiagramColorScheme;
|
||||
rejected: boolean;
|
||||
cached: RenderedDiagram | null;
|
||||
}
|
||||
|
||||
export interface MermaidRenderRequest {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
}
|
||||
|
||||
export interface MermaidRenderModel {
|
||||
revision: number;
|
||||
source: string;
|
||||
phase: MarkdownPhase;
|
||||
colorScheme: DiagramColorScheme;
|
||||
status: "pending" | "rendered" | "failed" | "rejected";
|
||||
visible: RenderedDiagram | null;
|
||||
}
|
||||
|
||||
export type MermaidRenderAction =
|
||||
| { type: "inputChanged"; input: MermaidRenderInput }
|
||||
| {
|
||||
type: "rendered";
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
dimensions: DiagramDimensions;
|
||||
}
|
||||
| { type: "renderFailed"; revision: number };
|
||||
|
||||
function reduceRendered(
|
||||
state: MermaidRenderModel,
|
||||
action: Extract<MermaidRenderAction, { type: "rendered" }>,
|
||||
): MermaidRenderModel {
|
||||
if (action.colorScheme !== state.colorScheme) {
|
||||
return state;
|
||||
}
|
||||
if (action.revision !== state.revision || action.source !== state.source) {
|
||||
const isStreamingPrefix =
|
||||
state.phase === "streaming" &&
|
||||
action.revision < state.revision &&
|
||||
state.source.startsWith(action.source);
|
||||
const isNewerPreview =
|
||||
state.visible === null || action.source.length > state.visible.source.length;
|
||||
if (!isStreamingPrefix || !isNewerPreview) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
visible: {
|
||||
source: action.source,
|
||||
colorScheme: action.colorScheme,
|
||||
...action.dimensions,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
status: "rendered",
|
||||
visible: {
|
||||
source: action.source,
|
||||
colorScheme: action.colorScheme,
|
||||
...action.dimensions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMermaidRenderModel(input: MermaidRenderInput): MermaidRenderModel {
|
||||
if (input.rejected) {
|
||||
return {
|
||||
revision: 1,
|
||||
source: input.source,
|
||||
phase: input.phase,
|
||||
colorScheme: input.colorScheme,
|
||||
status: "rejected",
|
||||
visible: input.phase === "complete" ? null : input.cached,
|
||||
};
|
||||
}
|
||||
return {
|
||||
revision: 1,
|
||||
source: input.source,
|
||||
phase: input.phase,
|
||||
colorScheme: input.colorScheme,
|
||||
status: "pending",
|
||||
visible: input.cached,
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceMermaidRenderModel(
|
||||
state: MermaidRenderModel,
|
||||
action: MermaidRenderAction,
|
||||
): MermaidRenderModel {
|
||||
if (action.type === "rendered") {
|
||||
return reduceRendered(state, action);
|
||||
}
|
||||
|
||||
if (action.type === "renderFailed") {
|
||||
if (action.revision !== state.revision) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
status: "failed",
|
||||
visible: state.phase === "complete" ? null : state.visible,
|
||||
};
|
||||
}
|
||||
|
||||
const { input } = action;
|
||||
const isSameRender = input.source === state.source && input.colorScheme === state.colorScheme;
|
||||
if (isSameRender) {
|
||||
if (input.phase === state.phase) {
|
||||
return state;
|
||||
}
|
||||
const finalFailure = input.phase === "complete" && state.status === "failed";
|
||||
return {
|
||||
...state,
|
||||
phase: input.phase,
|
||||
visible: finalFailure ? null : state.visible,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.rejected) {
|
||||
return {
|
||||
revision: state.revision + 1,
|
||||
source: input.source,
|
||||
phase: input.phase,
|
||||
colorScheme: input.colorScheme,
|
||||
status: "rejected",
|
||||
visible: input.phase === "complete" ? null : (input.cached ?? state.visible),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
revision: state.revision + 1,
|
||||
source: input.source,
|
||||
phase: input.phase,
|
||||
colorScheme: input.colorScheme,
|
||||
status: "pending",
|
||||
visible: input.cached ?? state.visible,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMermaidRenderRequest(state: MermaidRenderModel): MermaidRenderRequest | null {
|
||||
if (state.status !== "pending") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
revision: state.revision,
|
||||
source: state.source,
|
||||
colorScheme: state.colorScheme,
|
||||
};
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import mermaid from "mermaid";
|
||||
import {
|
||||
parseMermaidRuntimeRenderMessage,
|
||||
type MermaidRuntimeMessage,
|
||||
type MermaidRuntimeRenderMessage,
|
||||
} from "./messages";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ReactNativeWebView?: {
|
||||
postMessage?: (data: string) => void;
|
||||
};
|
||||
__PASEO_MERMAID_RUNTIME_RECEIVE__?: (message: unknown) => void;
|
||||
}
|
||||
}
|
||||
|
||||
function sendToHost(message: MermaidRuntimeMessage): void {
|
||||
window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage(message, "*");
|
||||
}
|
||||
}
|
||||
|
||||
function initializeMermaid(colorScheme: "light" | "dark"): void {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
suppressErrorRendering: true,
|
||||
theme: colorScheme === "dark" ? "dark" : "default",
|
||||
secure: [
|
||||
"secure",
|
||||
"securityLevel",
|
||||
"startOnLoad",
|
||||
"maxTextSize",
|
||||
"suppressErrorRendering",
|
||||
"maxEdges",
|
||||
"theme",
|
||||
"themeVariables",
|
||||
"themeCSS",
|
||||
],
|
||||
flowchart: { htmlLabels: false },
|
||||
class: { htmlLabels: false },
|
||||
});
|
||||
}
|
||||
|
||||
function setViewport(interactive: boolean): void {
|
||||
document
|
||||
.querySelector('meta[name="viewport"]')
|
||||
?.setAttribute(
|
||||
"content",
|
||||
interactive
|
||||
? "width=device-width, initial-scale=1, maximum-scale=8"
|
||||
: "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no",
|
||||
);
|
||||
}
|
||||
|
||||
let latestRevision = 0;
|
||||
let pendingRender: MermaidRuntimeRenderMessage | null = null;
|
||||
let isRendering = false;
|
||||
let isDrainScheduled = false;
|
||||
|
||||
interface DiagramSize {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mermaid emits `width="100%"` with an inline `max-width` in pixels, so the diagram renders at
|
||||
* its natural width and never fills a larger box. The host sizes this frame, so let the diagram
|
||||
* follow the frame in both directions instead.
|
||||
*/
|
||||
function stretchToFrame(svg: SVGSVGElement): void {
|
||||
svg.style.maxWidth = "100%";
|
||||
svg.style.maxHeight = "100%";
|
||||
svg.style.width = "100%";
|
||||
svg.style.height = "100%";
|
||||
}
|
||||
|
||||
/**
|
||||
* The reported size is the host's content size, and the host feeds it back as this frame's size.
|
||||
* Measuring the rendered box would make that a loop: every re-render lands in a frame the last
|
||||
* measurement shrank by the container's padding, so a streaming diagram ratchets down to nothing.
|
||||
* The viewBox is the diagram's own size and doesn't move.
|
||||
*/
|
||||
function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSize {
|
||||
const viewBox = svg?.viewBox.baseVal;
|
||||
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
|
||||
return { height: Math.ceil(viewBox.height), width: Math.ceil(viewBox.width) };
|
||||
}
|
||||
const rect = svg?.getBoundingClientRect();
|
||||
return {
|
||||
height: Math.ceil(rect?.height ?? host.scrollHeight),
|
||||
width: Math.ceil(rect?.width ?? host.scrollWidth),
|
||||
};
|
||||
}
|
||||
|
||||
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
|
||||
try {
|
||||
initializeMermaid(message.colorScheme);
|
||||
const { svg } = await mermaid.render(`paseo-mermaid-${message.revision}`, message.source);
|
||||
if (message.revision !== latestRevision) {
|
||||
return;
|
||||
}
|
||||
setViewport(message.interactive);
|
||||
const host = document.getElementById("diagram");
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
host.innerHTML = svg;
|
||||
const element = host.querySelector("svg");
|
||||
if (element) {
|
||||
stretchToFrame(element);
|
||||
}
|
||||
const size = measureDiagram(host, element);
|
||||
sendToHost({
|
||||
type: "rendered",
|
||||
revision: message.revision,
|
||||
source: message.source,
|
||||
colorScheme: message.colorScheme,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
});
|
||||
} catch {
|
||||
if (message.revision === latestRevision) {
|
||||
sendToHost({ type: "renderError", revision: message.revision });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function drainRenderQueue(): Promise<void> {
|
||||
if (isRendering) {
|
||||
return;
|
||||
}
|
||||
isRendering = true;
|
||||
try {
|
||||
while (pendingRender) {
|
||||
const next = pendingRender;
|
||||
pendingRender = null;
|
||||
await render(next);
|
||||
}
|
||||
} finally {
|
||||
isRendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
function receiveRender(value: unknown): void {
|
||||
const message = parseMermaidRuntimeRenderMessage(value);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
latestRevision = message.revision;
|
||||
pendingRender = message;
|
||||
if (isRendering || isDrainScheduled) {
|
||||
return;
|
||||
}
|
||||
isDrainScheduled = true;
|
||||
window.setTimeout(() => {
|
||||
isDrainScheduled = false;
|
||||
void drainRenderQueue();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
window.__PASEO_MERMAID_RUNTIME_RECEIVE__ = receiveRender;
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source === window.parent) {
|
||||
receiveRender(event.data);
|
||||
}
|
||||
});
|
||||
|
||||
sendToHost({ type: "bridgeReady" });
|
||||
File diff suppressed because one or more lines are too long
@@ -1,91 +0,0 @@
|
||||
import type { DiagramColorScheme, DiagramDimensions } from "../render-model";
|
||||
|
||||
export interface MermaidRuntimeRenderMessage {
|
||||
type: "render";
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
export type MermaidRuntimeMessage =
|
||||
| { type: "bridgeReady" }
|
||||
| ({
|
||||
type: "rendered";
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
} & DiagramDimensions)
|
||||
| { type: "renderError"; revision: number };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isColorScheme(value: unknown): value is DiagramColorScheme {
|
||||
return value === "light" || value === "dark";
|
||||
}
|
||||
|
||||
export function parseMermaidRuntimeRenderMessage(
|
||||
value: unknown,
|
||||
): MermaidRuntimeRenderMessage | null {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.type !== "render" ||
|
||||
typeof value.revision !== "number" ||
|
||||
!Number.isInteger(value.revision) ||
|
||||
typeof value.source !== "string" ||
|
||||
!isColorScheme(value.colorScheme) ||
|
||||
typeof value.interactive !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "render",
|
||||
revision: value.revision,
|
||||
source: value.source,
|
||||
colorScheme: value.colorScheme,
|
||||
interactive: value.interactive,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMermaidRuntimeMessage(value: unknown): MermaidRuntimeMessage | null {
|
||||
if (!isRecord(value) || typeof value.type !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (value.type === "bridgeReady") {
|
||||
return { type: "bridgeReady" };
|
||||
}
|
||||
if (
|
||||
value.type === "renderError" &&
|
||||
typeof value.revision === "number" &&
|
||||
Number.isInteger(value.revision)
|
||||
) {
|
||||
return { type: "renderError", revision: value.revision };
|
||||
}
|
||||
if (
|
||||
value.type === "rendered" &&
|
||||
typeof value.revision === "number" &&
|
||||
Number.isInteger(value.revision) &&
|
||||
typeof value.source === "string" &&
|
||||
isColorScheme(value.colorScheme) &&
|
||||
typeof value.height === "number" &&
|
||||
Number.isFinite(value.height) &&
|
||||
typeof value.width === "number" &&
|
||||
Number.isFinite(value.width)
|
||||
) {
|
||||
return {
|
||||
type: "rendered",
|
||||
revision: value.revision,
|
||||
source: value.source,
|
||||
colorScheme: value.colorScheme,
|
||||
height: value.height,
|
||||
width: value.width,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function serializeMermaidRuntimeRenderMessage(message: MermaidRuntimeRenderMessage): string {
|
||||
return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MermaidRenderRequest } from "../render-model";
|
||||
import { MermaidRuntimeRequestDriver } from "./request-driver";
|
||||
|
||||
function request(revision: number, source: string): MermaidRenderRequest {
|
||||
return { revision, source, colorScheme: "dark" };
|
||||
}
|
||||
|
||||
describe("MermaidRuntimeRequestDriver", () => {
|
||||
it("tries queued streaming prefixes in order until the runtime renders one", () => {
|
||||
const driver = new MermaidRuntimeRequestDriver();
|
||||
const first = request(1, "flow");
|
||||
const valid = request(2, "flowchart TD\nA --> B\n");
|
||||
const transient = request(3, "flowchart TD\nA --> B\nB --");
|
||||
|
||||
expect(driver.update(first)).toBeNull();
|
||||
expect(driver.update(valid)).toBeNull();
|
||||
expect(driver.update(transient)).toBeNull();
|
||||
expect(driver.ready()).toBe(valid);
|
||||
expect(driver.settled(valid.revision, true)).toBe(transient);
|
||||
});
|
||||
|
||||
it("keeps completed lines but collapses incomplete token revisions before readiness", () => {
|
||||
const driver = new MermaidRuntimeRequestDriver();
|
||||
const partial = request(1, "flowchart TD\nA --");
|
||||
const completed = request(2, "flowchart TD\nA --> B\n");
|
||||
const nextPartial = request(3, "flowchart TD\nA --> B\nB --");
|
||||
const latestPartial = request(4, "flowchart TD\nA --> B\nB -->");
|
||||
|
||||
driver.update(partial);
|
||||
driver.update(completed);
|
||||
driver.update(nextPartial);
|
||||
driver.update(latestPartial);
|
||||
|
||||
expect(driver.ready()).toBe(completed);
|
||||
expect(driver.settled(completed.revision, true)).toBe(latestPartial);
|
||||
});
|
||||
|
||||
it("collapses later updates to the newest request after the first render", () => {
|
||||
const driver = new MermaidRuntimeRequestDriver();
|
||||
const first = request(1, "flowchart TD\nA --> B");
|
||||
const second = request(2, "flowchart TD\nA --> B\nB --> C");
|
||||
const latest = request(3, "flowchart TD\nA --> B\nB --> D");
|
||||
|
||||
driver.update(first);
|
||||
expect(driver.ready()).toBe(first);
|
||||
expect(driver.settled(first.revision, true)).toBeNull();
|
||||
expect(driver.update(second)).toBe(second);
|
||||
expect(driver.update(latest)).toBeNull();
|
||||
expect(driver.settled(second.revision, false)).toBe(latest);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { MermaidRenderRequest } from "../render-model";
|
||||
|
||||
function isCompletedLineCandidate(source: string): boolean {
|
||||
return source.endsWith("\n") && source.trim().includes("\n");
|
||||
}
|
||||
|
||||
export class MermaidRuntimeRequestDriver {
|
||||
private readyState = false;
|
||||
private renderedOnce = false;
|
||||
private inFlight: MermaidRenderRequest | null = null;
|
||||
private queued: MermaidRenderRequest[] = [];
|
||||
|
||||
update(request: MermaidRenderRequest | null): MermaidRenderRequest | null {
|
||||
if (!request || this.hasRevision(request.revision)) {
|
||||
return null;
|
||||
}
|
||||
if (this.renderedOnce) {
|
||||
this.queued = [request];
|
||||
} else {
|
||||
const lastIndex = this.queued.length - 1;
|
||||
const last = this.queued[lastIndex];
|
||||
if (last && !isCompletedLineCandidate(last.source)) {
|
||||
this.queued[lastIndex] = request;
|
||||
} else {
|
||||
this.queued.push(request);
|
||||
}
|
||||
}
|
||||
return this.takeNext();
|
||||
}
|
||||
|
||||
ready(): MermaidRenderRequest | null {
|
||||
this.readyState = true;
|
||||
return this.takeNext();
|
||||
}
|
||||
|
||||
settled(revision: number, rendered: boolean): MermaidRenderRequest | null {
|
||||
if (this.inFlight?.revision !== revision) {
|
||||
return null;
|
||||
}
|
||||
this.inFlight = null;
|
||||
if (rendered) {
|
||||
this.renderedOnce = true;
|
||||
const latest = this.queued.at(-1);
|
||||
this.queued = latest ? [latest] : [];
|
||||
}
|
||||
return this.takeNext();
|
||||
}
|
||||
|
||||
private hasRevision(revision: number): boolean {
|
||||
return (
|
||||
this.inFlight?.revision === revision ||
|
||||
this.queued.some((request) => request.revision === revision)
|
||||
);
|
||||
}
|
||||
|
||||
private takeNext(): MermaidRenderRequest | null {
|
||||
if (!this.readyState || this.inFlight || this.queued.length === 0) {
|
||||
return null;
|
||||
}
|
||||
this.inFlight = this.queued.shift() ?? null;
|
||||
return this.inFlight;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mermaidRuntimeHtml } from "./html.gen";
|
||||
|
||||
describe("Mermaid runtime document", () => {
|
||||
it("ships a closed network policy", () => {
|
||||
expect(mermaidRuntimeHtml).toContain("default-src 'none'");
|
||||
expect(mermaidRuntimeHtml).toContain("connect-src 'none'");
|
||||
expect(mermaidRuntimeHtml).toContain("img-src data: blob:");
|
||||
expect(mermaidRuntimeHtml).toContain("font-src 'none'");
|
||||
expect(mermaidRuntimeHtml).toContain("frame-src 'none'");
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mermaidRuntimeHtml } from "./html.gen";
|
||||
import { parseMermaidRuntimeMessage, type MermaidRuntimeMessage } from "./messages";
|
||||
|
||||
const mountedFrames: HTMLIFrameElement[] = [];
|
||||
|
||||
function waitForRuntimeMessage(
|
||||
frame: HTMLIFrameElement,
|
||||
predicate: (message: MermaidRuntimeMessage) => boolean,
|
||||
): Promise<MermaidRuntimeMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
window.removeEventListener("message", receive);
|
||||
reject(new Error("Timed out waiting for Mermaid runtime"));
|
||||
}, 10_000);
|
||||
function receive(event: MessageEvent): void {
|
||||
if (event.source !== frame.contentWindow) {
|
||||
return;
|
||||
}
|
||||
const message = parseMermaidRuntimeMessage(event.data);
|
||||
if (!message || !predicate(message)) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(timeout);
|
||||
window.removeEventListener("message", receive);
|
||||
resolve(message);
|
||||
}
|
||||
window.addEventListener("message", receive);
|
||||
});
|
||||
}
|
||||
|
||||
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.sandbox.add("allow-scripts");
|
||||
if (size) {
|
||||
frame.style.width = `${size.width}px`;
|
||||
frame.style.height = `${size.height}px`;
|
||||
}
|
||||
const ready = waitForRuntimeMessage(frame, (message) => message.type === "bridgeReady");
|
||||
frame.srcdoc = mermaidRuntimeHtml;
|
||||
document.body.append(frame);
|
||||
mountedFrames.push(frame);
|
||||
await ready;
|
||||
return frame;
|
||||
}
|
||||
|
||||
function renderedSize(message: MermaidRuntimeMessage): { height: number; width: number } {
|
||||
if (message.type !== "rendered") {
|
||||
throw new Error(`Expected a rendered diagram, got ${message.type}`);
|
||||
}
|
||||
return { height: message.height, width: message.width };
|
||||
}
|
||||
|
||||
function render(
|
||||
frame: HTMLIFrameElement,
|
||||
input: { revision: number; source: string },
|
||||
): Promise<MermaidRuntimeMessage> {
|
||||
const response = waitForRuntimeMessage(
|
||||
frame,
|
||||
(message) =>
|
||||
message.type !== "bridgeReady" &&
|
||||
"revision" in message &&
|
||||
message.revision === input.revision,
|
||||
);
|
||||
frame.contentWindow?.postMessage(
|
||||
{
|
||||
type: "render",
|
||||
revision: input.revision,
|
||||
source: input.source,
|
||||
colorScheme: "dark",
|
||||
interactive: false,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const frame of mountedFrames.splice(0)) {
|
||||
frame.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("Mermaid sandbox runtime", () => {
|
||||
it("renders successive valid streaming prefixes and reports an invalid prefix", async () => {
|
||||
const frame = await mountRuntime();
|
||||
const firstSource = "flowchart TD\nA --> B";
|
||||
const secondSource = `${firstSource}\nB --> C`;
|
||||
|
||||
const first = await render(frame, { revision: 1, source: firstSource });
|
||||
const invalid = await render(frame, { revision: 2, source: "not a mermaid diagram" });
|
||||
const second = await render(frame, { revision: 3, source: secondSource });
|
||||
|
||||
expect(first).toMatchObject({ type: "rendered", revision: 1, source: firstSource });
|
||||
expect(invalid).toEqual({ type: "renderError", revision: 2 });
|
||||
expect(second).toMatchObject({ type: "rendered", revision: 3, source: secondSource });
|
||||
});
|
||||
|
||||
/**
|
||||
* The host sizes this frame from the reported size, so a size that depends on the frame feeds
|
||||
* back: every re-render measures inside a frame the previous measurement already shrank by the
|
||||
* container's padding, and a streaming diagram ratchets down to a few pixels.
|
||||
*/
|
||||
it("reports the same size whatever frame the host gives it", async () => {
|
||||
const source = "flowchart TD\nA[Start] --> B[Middle]\nB --> C[Ship]";
|
||||
const narrowFrame = await mountRuntime({ height: 60, width: 60 });
|
||||
const wideFrame = await mountRuntime({ height: 600, width: 900 });
|
||||
|
||||
const narrow = await render(narrowFrame, { revision: 1, source });
|
||||
const wide = await render(wideFrame, { revision: 1, source });
|
||||
|
||||
expect(renderedSize(narrow)).toEqual(renderedSize(wide));
|
||||
});
|
||||
|
||||
it("coalesces queued input and never reports an obsolete result", async () => {
|
||||
const frame = await mountRuntime();
|
||||
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
|
||||
const currentSource = "flowchart TD\nCurrent --> Result";
|
||||
const obsoleteResponses: MermaidRuntimeMessage[] = [];
|
||||
function collect(event: MessageEvent): void {
|
||||
if (event.source !== frame.contentWindow) {
|
||||
return;
|
||||
}
|
||||
const message = parseMermaidRuntimeMessage(event.data);
|
||||
if (message && message.type !== "bridgeReady" && message.revision === 10) {
|
||||
obsoleteResponses.push(message);
|
||||
}
|
||||
}
|
||||
window.addEventListener("message", collect);
|
||||
frame.contentWindow?.postMessage(
|
||||
{
|
||||
type: "render",
|
||||
revision: 10,
|
||||
source: obsoleteSource,
|
||||
colorScheme: "dark",
|
||||
interactive: false,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
const current = await render(frame, { revision: 11, source: currentSource });
|
||||
window.removeEventListener("message", collect);
|
||||
|
||||
expect(current).toMatchObject({ type: "rendered", revision: 11, source: currentSource });
|
||||
expect(obsoleteResponses).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { containsUnsafeMermaidSource } from "./source-policy";
|
||||
|
||||
describe("containsUnsafeMermaidSource", () => {
|
||||
it("rejects resource-bearing constructs", () => {
|
||||
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ img: "https://x/y.png" }')).toBe(true);
|
||||
expect(containsUnsafeMermaidSource("flowchart TD\n A@{ icon: 'pack:name' }")).toBe(true);
|
||||
expect(
|
||||
containsUnsafeMermaidSource('%%{init: {"themeCSS": "a { color: red }"}}%%\ngraph TD'),
|
||||
).toBe(true);
|
||||
expect(containsUnsafeMermaidSource("graph TD\n A[url(http://x)]")).toBe(true);
|
||||
expect(containsUnsafeMermaidSource("graph TD\n A[@import 'x']")).toBe(true);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["<img src=x>"]')).toBe(true);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["<i class=x>styled</i>"]')).toBe(true);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["<img src=x>"]')).toBe(true);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["</b>"]')).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects all shape-data constructs, including yaml key evasions", () => {
|
||||
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "img": "https://x/y.png" }')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(containsUnsafeMermaidSource("flowchart TD\n A@{ 'img': 'https://x/y.png' }")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
containsUnsafeMermaidSource('flowchart TD\n A@{ "\\u0069mg": "https://x/y.png" }'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
containsUnsafeMermaidSource('flowchart TD\n A@{ "\\u{69}mg": "https://x/y.png" }'),
|
||||
).toBe(true);
|
||||
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "\\x69mg": "https://x/y.png" }')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
containsUnsafeMermaidSource('flowchart TD\n A@{ "\\U00000069mg": "https://attacker/x" }'),
|
||||
).toBe(true);
|
||||
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "icon": "pack:name" }')).toBe(true);
|
||||
expect(
|
||||
containsUnsafeMermaidSource(
|
||||
'flowchart TD\n A@{ ? img # comment\n : "https://attacker/x" }',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
containsUnsafeMermaidSource(
|
||||
'flowchart TD\n A@{ dummy: &k img\n ? *k # comment\n : "https://attacker/x" }',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed for malformed or out-of-range escapes without throwing", () => {
|
||||
expect(() =>
|
||||
containsUnsafeMermaidSource('graph TD\n A["\\u{110000} disguised"]'),
|
||||
).not.toThrow();
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["\\u{110000} disguised"]')).toBe(true);
|
||||
expect(() =>
|
||||
containsUnsafeMermaidSource('graph TD\n A["\\u{FFFFFF} disguised"]'),
|
||||
).not.toThrow();
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["\\u{FFFFFF} disguised"]')).toBe(true);
|
||||
});
|
||||
|
||||
it("allows ordinary diagrams including formatting-only labels", () => {
|
||||
expect(containsUnsafeMermaidSource("flowchart TD\n A[Start] --> B{Choice}")).toBe(false);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["line one<br>line two"]')).toBe(false);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["line one<br/>line two"]')).toBe(false);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["<i>formatted</i>"]')).toBe(false);
|
||||
expect(containsUnsafeMermaidSource("sequenceDiagram\n Alice->>Bob: a < b and x > y")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(containsUnsafeMermaidSource("flowchart TD\n A@{ shape: rect }")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
// Mermaid can fetch external resources while *rendering* — image shapes
|
||||
// (`A@{ img: "url" }`) construct an Image and await decode before any output
|
||||
// sanitization runs, and CSS can pull url()/@import — so a prompt-injected
|
||||
// diagram could exfiltrate data in a request URL. securityLevel "strict" does
|
||||
// not prevent this (mermaid-js/mermaid#7645). Mermaid 11.16 parses the shape
|
||||
// metadata object with yaml.JSON_SCHEMA semantics, including aliases and
|
||||
// explicit keys; matching those keys safely would require a real parser. We
|
||||
// therefore reject any `@{ ... }` shape-data construct up front and fall back
|
||||
// to the source code block. Formatting-only `<br>` and `<i>` tags are common
|
||||
// in generated labels and carry no resource-bearing attributes; all other
|
||||
// tags (and entity-encoded text that could smuggle one) are rejected.
|
||||
const UNSAFE_MERMAID_SOURCE =
|
||||
/@\s*\{|url\s*\(|@import\b|themeCSS|&#|<(?!\/?(?:br|i)\s*\/?>)[a-z!/]/i;
|
||||
|
||||
// Mermaid labels can contain escaped text. Decode the escape forms we care
|
||||
// about before running the denylist so disguised HTML still gets caught.
|
||||
// Invalid escape sequences fail closed and fall back to the source block.
|
||||
function normalizeMermaidSource(code: string): string | null {
|
||||
try {
|
||||
return code
|
||||
.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (_, hex: string) =>
|
||||
decodeCodePointEscape(Number.parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex: string) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/["'`\\]/g, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeCodePointEscape(value: number): string {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 0x10ffff) {
|
||||
throw new RangeError("Invalid Unicode code point");
|
||||
}
|
||||
return String.fromCodePoint(value);
|
||||
}
|
||||
|
||||
export function containsUnsafeMermaidSource(code: string): boolean {
|
||||
if (UNSAFE_MERMAID_SOURCE.test(code)) {
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeMermaidSource(code);
|
||||
if (normalized === null) {
|
||||
return true;
|
||||
}
|
||||
return UNSAFE_MERMAID_SOURCE.test(normalized);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
||||
import {
|
||||
createMermaidRenderModel,
|
||||
getMermaidRenderRequest,
|
||||
reduceMermaidRenderModel,
|
||||
type DiagramColorScheme,
|
||||
type DiagramDimensions,
|
||||
type RenderedDiagram,
|
||||
} from "./render-model";
|
||||
import { containsUnsafeMermaidSource } from "./source-policy";
|
||||
import type { MarkdownPhase } from "../types";
|
||||
|
||||
const renderCache = new Map<string, RenderedDiagram>();
|
||||
const RENDER_CACHE_LIMIT = 50;
|
||||
|
||||
function cacheKey(source: string, colorScheme: DiagramColorScheme): string {
|
||||
return `${colorScheme}\u0000${source}`;
|
||||
}
|
||||
|
||||
function readCachedRender(source: string, colorScheme: DiagramColorScheme): RenderedDiagram | null {
|
||||
return renderCache.get(cacheKey(source, colorScheme)) ?? null;
|
||||
}
|
||||
|
||||
function cacheRender(rendered: RenderedDiagram): void {
|
||||
if (renderCache.size >= RENDER_CACHE_LIMIT) {
|
||||
const oldest = renderCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
renderCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
renderCache.set(cacheKey(rendered.source, rendered.colorScheme), rendered);
|
||||
}
|
||||
|
||||
export function useMermaidRenderModel({
|
||||
source,
|
||||
phase,
|
||||
colorScheme,
|
||||
}: {
|
||||
source: string;
|
||||
phase: MarkdownPhase;
|
||||
colorScheme: DiagramColorScheme;
|
||||
}) {
|
||||
const renderInput = useMemo(
|
||||
() => ({
|
||||
source,
|
||||
phase,
|
||||
colorScheme,
|
||||
rejected: containsUnsafeMermaidSource(source),
|
||||
cached: readCachedRender(source, colorScheme),
|
||||
}),
|
||||
[colorScheme, phase, source],
|
||||
);
|
||||
const [state, dispatch] = useReducer(
|
||||
reduceMermaidRenderModel,
|
||||
renderInput,
|
||||
createMermaidRenderModel,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "inputChanged", input: renderInput });
|
||||
}, [renderInput]);
|
||||
|
||||
const rendered = useCallback(
|
||||
(response: {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
dimensions: DiagramDimensions;
|
||||
}) => {
|
||||
const cached: RenderedDiagram = {
|
||||
source: response.source,
|
||||
colorScheme: response.colorScheme,
|
||||
...response.dimensions,
|
||||
};
|
||||
cacheRender(cached);
|
||||
dispatch({
|
||||
type: "rendered",
|
||||
revision: response.revision,
|
||||
source: response.source,
|
||||
colorScheme: response.colorScheme,
|
||||
dimensions: response.dimensions,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const renderFailed = useCallback((revision: number) => {
|
||||
dispatch({ type: "renderFailed", revision });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
request: getMermaidRenderRequest(state),
|
||||
rendered,
|
||||
renderFailed,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { TextStyle } from "react-native";
|
||||
|
||||
export type MarkdownPhase = "streaming" | "complete";
|
||||
|
||||
export interface MarkdownFenceRendererProps {
|
||||
code: string;
|
||||
phase: MarkdownPhase;
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
|
||||
|
||||
describe("splitHtmlishMarkdown", () => {
|
||||
it("renders heading text beside a linked picture without literal heading tags", () => {
|
||||
const source =
|
||||
'<h2><a href="https://example.com/r"><picture><source srcset="https://example.com/d.svg"><img alt="Retrigger" src="https://example.com/l.svg"></picture></a>Confidence Score: 5/5</h2>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Retrigger",
|
||||
src: "https://example.com/l.svg",
|
||||
href: "https://example.com/r",
|
||||
flowsWithText: true,
|
||||
},
|
||||
{ kind: "markdown", text: "Confidence Score: 5/5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["h1", "h2", "h3", "h4", "h5", "h6"])(
|
||||
"keeps a mixed %s heading together after preceding markdown",
|
||||
(heading) => {
|
||||
const source = `Intro\n\n<${heading}><img alt="Icon" src="https://example.com/icon.svg">Caption</${heading}>\n\nOutro`;
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{ kind: "markdown", text: "Intro\n\n" },
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Icon",
|
||||
src: "https://example.com/icon.svg",
|
||||
flowsWithText: true,
|
||||
},
|
||||
{ kind: "markdown", text: "Caption\n\nOutro" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves formatted text before and after images in a details heading", () => {
|
||||
const source =
|
||||
'<details><summary>More</summary><h2><strong>Before</strong><img alt="Icon" src="https://example.com/icon.svg"><em>After</em></h2></details>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "details",
|
||||
summary: "More",
|
||||
body: "**Before***After*",
|
||||
bodyParts: [
|
||||
{ kind: "markdown", text: "**Before**" },
|
||||
{ kind: "inlineImage", alt: "Icon", src: "https://example.com/icon.svg" },
|
||||
{ kind: "markdown", text: "*After*" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps mixed HTML headings literal in code examples", () => {
|
||||
const heading = '<h2><img src="https://example.com/icon.svg">Caption</h2>';
|
||||
const source = `Use \`${heading}\`\n\n\`\`\`html\n${heading}\n\`\`\``;
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("preserves unknown wrappers around text-only headings", () => {
|
||||
expect(splitHtmlishMarkdown("<section><h2>Heading</h2></section>")).toEqual([
|
||||
{ kind: "markdown", text: "<section>Heading</section>" },
|
||||
]);
|
||||
});
|
||||
|
||||
const inlineImageBody = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
|
||||
"",
|
||||
"The middleware now trusts any browser-like User-Agent for unauthenticated requests.",
|
||||
"",
|
||||
"```ts",
|
||||
'const isBrowser = userAgent.includes("Mozilla");',
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
const multiDetailsBody = [
|
||||
"### Bot Review",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary><h3>Important Files Changed</h3></summary>",
|
||||
"",
|
||||
"- `packages/server/src/server/session.ts`",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary><h3>Security Findings</h3></summary>",
|
||||
"",
|
||||
"No blocking findings.",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
"<!-- bot_other_comments_section -->",
|
||||
'<sub>Reviews (8): Last reviewed commit: “revert: undo parser” | <a href="https://app.greptile.com">Re-trigger Greptile</a></sub>',
|
||||
].join("\n");
|
||||
|
||||
it("classifies linked HTML images as generic inline images, not block markdown images", () => {
|
||||
const [image, text] = splitHtmlishMarkdown(inlineImageBody);
|
||||
|
||||
expect(image).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/assets/priority.svg?v=9",
|
||||
flowsWithText: true,
|
||||
});
|
||||
expect(text).toEqual({
|
||||
kind: "markdown",
|
||||
text: [
|
||||
" Spoofed browser User-Agent allows access control bypass",
|
||||
"",
|
||||
"The middleware now trusts any browser-like User-Agent for unauthenticated requests.",
|
||||
"",
|
||||
"```ts",
|
||||
'const isBrowser = userAgent.includes("Mozilla");',
|
||||
"```",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("flags both images as flowsWithText when two inline images precede title text on the same line", () => {
|
||||
const twoImageBody = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/priority.svg" align="top"></a> <a href="#"><img alt="Security" src="https://example.com/security.svg" align="top"></a> **Title text here**',
|
||||
"",
|
||||
"Body paragraph.",
|
||||
].join("\n");
|
||||
|
||||
const parts = splitHtmlishMarkdown(twoImageBody);
|
||||
const [first, second, third] = parts;
|
||||
|
||||
expect(first).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/priority.svg",
|
||||
flowsWithText: true,
|
||||
});
|
||||
expect(second).toEqual({ kind: "markdown", text: " " });
|
||||
expect(third).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Security",
|
||||
src: "https://example.com/security.svg",
|
||||
flowsWithText: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps safe width and height attributes on inline images", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<img alt="Small" src="https://example.com/small.svg" width="18" height="12">',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Small",
|
||||
src: "https://example.com/small.svg",
|
||||
width: 18,
|
||||
height: 12,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps safe non-empty image links", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<a href="https://example.com/details"><img alt="Small" src="https://example.com/small.svg"></a>',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Small",
|
||||
src: "https://example.com/small.svg",
|
||||
href: "https://example.com/details",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a linked picture inside a heading without exposing its HTML", () => {
|
||||
const source = [
|
||||
'<h2><a href="https://example.com/retrigger?id=73851569"><picture>',
|
||||
'<source media="(prefers-color-scheme: dark)" srcset="https://example.com/dark.svg">',
|
||||
'<source media="(prefers-color-scheme: light)" srcset="https://example.com/light.svg">',
|
||||
'<img alt="Retrigger" src="https://example.com/default.svg" width="85" height="24">',
|
||||
"</picture></a></h2>",
|
||||
"",
|
||||
"Confidence Score: 5/5",
|
||||
"",
|
||||
"<details><summary>Summary</summary>No outstanding findings.</details>",
|
||||
].join("\n");
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Retrigger",
|
||||
src: "https://example.com/default.svg",
|
||||
href: "https://example.com/retrigger?id=73851569",
|
||||
width: 85,
|
||||
height: 24,
|
||||
},
|
||||
{ kind: "markdown", text: "\n\nConfidence Score: 5/5\n\n" },
|
||||
{ kind: "details", summary: "Summary", body: "No outstanding findings." },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", ""],
|
||||
["<h3>", "</h3>"],
|
||||
['<a href="javascript:alert(1)">', "</a>"],
|
||||
])("uses the picture fallback without activating unsafe links (%s)", (wrapper, closing) => {
|
||||
const picture =
|
||||
'<picture><source srcset="https://example.com/alternate.svg"/><img src="https://example.com/default.svg" alt="Image"></picture>';
|
||||
|
||||
expect(splitHtmlishMarkdown(`${wrapper}${picture}${closing}`)).toEqual([
|
||||
{ kind: "inlineImage", src: "https://example.com/default.svg", alt: "Image" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps picture examples literal inside inline and fenced code", () => {
|
||||
const picture =
|
||||
'<picture><source srcset="https://example.com/alternate.svg"><img src="https://example.com/default.svg"></picture>';
|
||||
const source = `Use \`${picture}\`\n\n\`\`\`html\n${picture}\n\`\`\``;
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it.each(["p", "div"])("renders a linked picture inside %s", (wrapper) => {
|
||||
const source = `<${wrapper}><a href="https://example.com/page"><picture><source srcset="https://example.com/dark.svg"><img src="https://example.com/default.svg" alt="Image"></picture></a></${wrapper}>`;
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
src: "https://example.com/default.svg",
|
||||
alt: "Image",
|
||||
href: "https://example.com/page",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["h2", "p", "div"])("keeps text after an image-only %s on a separate row", (wrapper) => {
|
||||
const source = `<${wrapper}><img src="https://example.com/image.svg" alt="Image"></${wrapper}>Next section`;
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{ kind: "inlineImage", src: "https://example.com/image.svg", alt: "Image" },
|
||||
{ kind: "markdown", text: "Next section" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles deeply nested image wrappers without exhausting the stack", () => {
|
||||
const source = `${"<div>".repeat(10000)}<a href="https://example.com/page"><picture><img src="https://example.com/image.svg" alt="Image"></picture></a>${"</div>".repeat(10000)}`;
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
src: "https://example.com/image.svg",
|
||||
alt: "Image",
|
||||
href: "https://example.com/page",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves inline image parts inside details bodies", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<details><summary>Images</summary><a href="https://example.com/page"><img alt="Icon" src="https://example.com/icon.svg"></a> Inline text</details>',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "details",
|
||||
summary: "Images",
|
||||
body: "Inline text",
|
||||
bodyParts: [
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Icon",
|
||||
src: "https://example.com/icon.svg",
|
||||
href: "https://example.com/page",
|
||||
flowsWithText: true,
|
||||
},
|
||||
{ kind: "markdown", text: " Inline text" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not flag standalone inline images as flowing with text", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown('<img alt="Shot" src="https://example.com/shot.png">\n\nCaption below'),
|
||||
).toEqual([
|
||||
{ kind: "inlineImage", alt: "Shot", src: "https://example.com/shot.png" },
|
||||
{ kind: "markdown", text: "\n\nCaption below" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not flag mid-line inline images as flowing with text", () => {
|
||||
const [, image] = splitHtmlishMarkdown(
|
||||
'Before <img alt="Icon" src="https://example.com/icon.png"> after',
|
||||
);
|
||||
|
||||
expect(image).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Icon",
|
||||
src: "https://example.com/icon.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves ordinary markdown images on the markdown path", () => {
|
||||
const source = "";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("leaves unsafe HTML image sources inert", () => {
|
||||
const source = '<img alt="Bad" src="javascript:alert(1)">';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("removes raw image anchor and image tags from rendered markdown text", () => {
|
||||
const text = splitHtmlishMarkdown(inlineImageBody)
|
||||
.map((part) => (part.kind === "markdown" ? part.text : ""))
|
||||
.join("");
|
||||
|
||||
expect(text).not.toContain("<a ");
|
||||
expect(text).not.toContain("<img ");
|
||||
expect(text).not.toContain("</a>");
|
||||
});
|
||||
|
||||
it("unwraps sub text and strips HTML comments", () => {
|
||||
const parts = splitHtmlishMarkdown(multiDetailsBody);
|
||||
const tail = parts.at(-1);
|
||||
|
||||
expect(tail).toEqual({
|
||||
kind: "markdown",
|
||||
text: "\n\nReviews (8): Last reviewed commit: “revert: undo parser” | [Re-trigger Greptile](https://app.greptile.com)",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leak stray closing details tags across multiple details blocks", () => {
|
||||
const renderedText = splitHtmlishMarkdown(multiDetailsBody)
|
||||
.map((part) => {
|
||||
if (part.kind === "markdown") return part.text;
|
||||
if (part.kind === "details") return `${part.summary}\n${part.body}`;
|
||||
return part.alt;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
expect(renderedText).not.toContain("</details>");
|
||||
expect(renderedText).not.toContain("<!--");
|
||||
expect(splitHtmlishMarkdown(multiDetailsBody).slice(0, 4)).toMatchObject([
|
||||
{ kind: "markdown", text: "### Bot Review\n\n" },
|
||||
{ kind: "details", summary: "Important Files Changed" },
|
||||
{ kind: "markdown", text: "\n\n" },
|
||||
{ kind: "details", summary: "Security Findings" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves paragraph boundaries around title, prose, and code block", () => {
|
||||
const text = splitHtmlishMarkdown(inlineImageBody)
|
||||
.map((part) => (part.kind === "markdown" ? part.text : ""))
|
||||
.join("");
|
||||
|
||||
expect(text).toContain(
|
||||
"Spoofed browser User-Agent allows access control bypass\n\nThe middleware now trusts",
|
||||
);
|
||||
expect(text).toContain("unauthenticated requests.\n\n```ts\nconst isBrowser");
|
||||
});
|
||||
|
||||
it("keeps product-specific image language out of parser and renderer logic", () => {
|
||||
const parserSource = readFileSync(new URL("./html-ish.ts", import.meta.url), "utf8");
|
||||
const rendererSource = readFileSync(new URL("./renderer.tsx", import.meta.url), "utf8");
|
||||
const productionSource = `${parserSource}\n${rendererSource}`;
|
||||
|
||||
expect(productionSource).not.toMatch(/Greptile|badge|P0|P1|P2|greptile-static-assets/i);
|
||||
});
|
||||
|
||||
it("parses Greptile-style details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
"<details><summary><h3>Greptile Summary</h3></summary>Body markdown</details>",
|
||||
),
|
||||
).toEqual([{ kind: "details", summary: "Greptile Summary", body: "Body markdown" }]);
|
||||
});
|
||||
|
||||
it("keeps details inside fenced code as literal markdown", () => {
|
||||
const source = "```html\n<details><summary>Example</summary>Body</details>\n```";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("keeps details inside inline code on native runtimes without copied array sorting", () => {
|
||||
const source = "Use `<details><summary>Example</summary>Body</details>` as an example.";
|
||||
|
||||
expect(withoutArrayToSorted(() => splitHtmlishMarkdown(source))).toEqual([
|
||||
{ kind: "markdown", text: source },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps details inside inline code as literal markdown", () => {
|
||||
const source = "Use `<details><summary>Example</summary>Body</details>` as an example.";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("terminates when an unmatched backtick follows a closed inline code span", () => {
|
||||
const source = "Use `<details><summary>Example</summary>Body</details>` before `dangling";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("still parses normal details outside code", () => {
|
||||
expect(splitHtmlishMarkdown("`code`\n<details><summary>Real</summary>Body</details>")).toEqual([
|
||||
{ kind: "markdown", text: "`code`\n" },
|
||||
{ kind: "details", summary: "Real", body: "Body" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves text before and after details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown("Before\n<details><summary>More</summary>Hidden</details>\nAfter"),
|
||||
).toEqual([
|
||||
{ kind: "markdown", text: "Before\n" },
|
||||
{ kind: "details", summary: "More", body: "Hidden" },
|
||||
{ kind: "markdown", text: "\nAfter" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses multiple details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
"<details><summary>One</summary>A</details><details><summary>Two</summary>B</details>",
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "details", summary: "One", body: "A" },
|
||||
{ kind: "details", summary: "Two", body: "B" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes br and simple code tags into markdown", () => {
|
||||
expect(normalizeHtmlishMarkdown("Line 1<br/>Line 2 <code>safe-value</code>")).toBe(
|
||||
"Line 1\nLine 2 `safe-value`",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes HTML table cells and inline formatting into markdown", () => {
|
||||
expect(
|
||||
normalizeHtmlishMarkdown(
|
||||
"<table><tr><td><strong>Score</strong>: 78</td></tr><tr><td><strong>No security concerns identified</strong></td></tr><tr><td><strong>Recommended focus areas for review</strong></td><td>Bearer authentication</td></tr></table>",
|
||||
),
|
||||
).toBe(
|
||||
"\n- **Score**: 78\n- **No security concerns identified**\n- **Recommended focus areas for review**: Bearer authentication\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves complex code tags inert instead of parsing HTML", () => {
|
||||
expect(normalizeHtmlishMarkdown('<code onclick="evil()"><script>x</script></code>')).toBe(
|
||||
'<code onclick="evil()"><script>x</script></code>',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps table rows after a comparison that only looks like a tag", () => {
|
||||
const source = [
|
||||
"| ID | Note | Status |",
|
||||
"|---|---|---|",
|
||||
"| a-1 | accepts 1<size<2 only | open |",
|
||||
"| a-2 | second row | open |",
|
||||
"",
|
||||
"Later x -> y.",
|
||||
].join("\n");
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it.each(["See <https://example.com/docs> for more.", "Stop when a<b", "Keep </ b> and a <b"])(
|
||||
"keeps %j literal because it is not HTML",
|
||||
(source) => {
|
||||
expect(normalizeHtmlishMarkdown(source)).toBe(source);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"Call As<Integer>() before limits<DWORD>::max()",
|
||||
"<Kbd Data-Key='K'>Ctrl</Kbd> then <Foo/>",
|
||||
])("echoes unknown tags in %j as written", (source) => {
|
||||
expect(normalizeHtmlishMarkdown(source)).toBe(source);
|
||||
});
|
||||
|
||||
it("rebuilds an unknown tag that a code span splits", () => {
|
||||
expect(normalizeHtmlishMarkdown('<Tip title="`x`">hi</Tip>')).toBe("`x`<tip title>hi</Tip>");
|
||||
});
|
||||
|
||||
it("still reads tags and comments next to literal angle brackets", () => {
|
||||
expect(normalizeHtmlishMarkdown("1<n<2 <b>bold</b><!-- hidden --> 3<4")).toBe(
|
||||
"1<n<2 **bold** 3<4",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to inert markdown when details are unclosed", () => {
|
||||
const source = "<details><summary>Open</summary>Still open";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("falls back to inert markdown when summary is missing", () => {
|
||||
const source = "<details>Hidden</details>";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("does not execute or render unknown HTML as structured content", () => {
|
||||
const source =
|
||||
'<script>alert(1)</script><details onclick="evil()"><summary>Safe</summary><iframe src="x"></iframe></details>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{ kind: "markdown", text: "<script>alert(1)</script>" },
|
||||
{ kind: "details", summary: "Safe", body: '<iframe src="x"></iframe>' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function withoutArrayToSorted<T>(callback: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, "toSorted");
|
||||
Reflect.deleteProperty(Array.prototype, "toSorted");
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
if (descriptor) {
|
||||
Reflect.defineProperty(Array.prototype, "toSorted", descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,926 +0,0 @@
|
||||
import { Parser } from "htmlparser2";
|
||||
|
||||
export interface MarkdownTextPart {
|
||||
kind: "markdown";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MarkdownDetailsPart {
|
||||
kind: "details";
|
||||
summary: string;
|
||||
body: string;
|
||||
bodyParts?: MarkdownDisplayPart[];
|
||||
}
|
||||
|
||||
export interface MarkdownInlineImagePart {
|
||||
kind: "inlineImage";
|
||||
alt: string;
|
||||
src: string;
|
||||
href?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** The image starts a line and text continues on that same line. */
|
||||
flowsWithText?: boolean;
|
||||
}
|
||||
|
||||
export type MarkdownDisplayPart = MarkdownTextPart | MarkdownDetailsPart | MarkdownInlineImagePart;
|
||||
|
||||
const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
|
||||
const BACKTICK_RUN_RE = /`+/g;
|
||||
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
|
||||
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
|
||||
// CommonMark open and closing tags (spec "Raw HTML"); comments are matched separately.
|
||||
const HTML_ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`;
|
||||
const HTML_TAG_RE = new RegExp(
|
||||
String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${HTML_ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`,
|
||||
"y",
|
||||
);
|
||||
const VOID_HTML_TAGS = new Set(["br", "img", "source"]);
|
||||
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
|
||||
b: ["**", "**"],
|
||||
del: ["~~", "~~"],
|
||||
em: ["*", "*"],
|
||||
i: ["*", "*"],
|
||||
s: ["~~", "~~"],
|
||||
strike: ["~~", "~~"],
|
||||
strong: ["**", "**"],
|
||||
};
|
||||
|
||||
interface ProtectedMarkdownRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MarkdownImageDimensions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface HtmlTextToken {
|
||||
kind: "text";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface HtmlCommentToken {
|
||||
kind: "comment";
|
||||
}
|
||||
|
||||
interface HtmlTagToken {
|
||||
kind: "tag";
|
||||
name: string;
|
||||
closing: boolean;
|
||||
selfClosing: boolean;
|
||||
attributes: Record<string, string>;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
type HtmlToken = HtmlTextToken | HtmlCommentToken | HtmlTagToken;
|
||||
|
||||
interface InlineImageParseResult {
|
||||
part: MarkdownInlineImagePart;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MarkdownDelimiterMatch {
|
||||
index: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface HtmlishTokenParser {
|
||||
write(chunk: string): void;
|
||||
skip(length: number): void;
|
||||
end(): void;
|
||||
}
|
||||
|
||||
export function splitHtmlishMarkdown(source: string): MarkdownDisplayPart[] {
|
||||
return splitHtmlishTokens(tokenizeHtmlishMarkdown(source));
|
||||
}
|
||||
|
||||
function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
|
||||
const parts: MarkdownDisplayPart[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor];
|
||||
if (isOpenTag(token, "details")) {
|
||||
const closeIndex = findMatchingClose(tokens, cursor, "details");
|
||||
if (closeIndex !== null) {
|
||||
const details = parseDetailsTokens(tokens.slice(cursor + 1, closeIndex));
|
||||
if (details) {
|
||||
parts.push(details);
|
||||
cursor = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineImage = parseInlineImageAt(tokens, cursor);
|
||||
if (inlineImage) {
|
||||
parts.push(
|
||||
flowsWithFollowingText(tokens, cursor, inlineImage.end)
|
||||
? { ...inlineImage.part, flowsWithText: true }
|
||||
: inlineImage.part,
|
||||
);
|
||||
cursor = inlineImage.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isHeadingTag(token)) {
|
||||
const closeIndex = findMixedHeadingClose(tokens, cursor);
|
||||
if (closeIndex !== null) {
|
||||
// Headings render as their contents. Split those contents together so an
|
||||
// embedded image cannot strand the opening heading tag in a text slice.
|
||||
for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) {
|
||||
appendDisplayPart(parts, part);
|
||||
}
|
||||
cursor = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length;
|
||||
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseInlineImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null {
|
||||
const token = tokens[start];
|
||||
if (token?.kind !== "tag" || token.closing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token.name === "img") {
|
||||
const image = imageTokenToInlineImage(token, undefined);
|
||||
return image ? { part: image, end: start + 1 } : null;
|
||||
}
|
||||
|
||||
if (token.name !== "a" && token.name !== "picture" && !isImageBlockWrapper(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseWrappedImageAt(tokens, start);
|
||||
}
|
||||
|
||||
function parseWrappedImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null {
|
||||
// Walk an image-only wrapper chain once. Recursing over copied child slices
|
||||
// makes deeply nested review HTML quadratic and can exhaust the JS stack.
|
||||
const wrappers: string[] = [];
|
||||
let image: MarkdownInlineImagePart | null = null;
|
||||
let href: string | undefined;
|
||||
let hasLink = false;
|
||||
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
const child = tokens[index];
|
||||
if (child.kind === "comment" || isWhitespaceText(child)) {
|
||||
continue;
|
||||
}
|
||||
if (child.kind !== "tag") {
|
||||
return null;
|
||||
}
|
||||
if (child.closing) {
|
||||
if (!image || wrappers.pop() !== child.name) {
|
||||
return null;
|
||||
}
|
||||
if (wrappers.length === 0) {
|
||||
return { part: image, end: index + 1 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Pictures use their img fallback, not responsive source selection.
|
||||
if (child.name === "source" && wrappers.at(-1) === "picture") {
|
||||
continue;
|
||||
}
|
||||
if (image) {
|
||||
return null;
|
||||
}
|
||||
if (child.name === "img") {
|
||||
image = imageTokenToInlineImage(child, href);
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (child.name !== "a" && child.name !== "picture" && !isImageBlockWrapper(child)) {
|
||||
return null;
|
||||
}
|
||||
if (child.name === "a" && !hasLink) {
|
||||
href = safeHref(child.attributes.href);
|
||||
hasLink = true;
|
||||
}
|
||||
wrappers.push(child.name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isImageBlockWrapper(token: HtmlToken | undefined): boolean {
|
||||
return isHeadingTag(token) || isOpenTag(token, "p") || isOpenTag(token, "div");
|
||||
}
|
||||
|
||||
function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number): boolean {
|
||||
if (isImageBlockWrapper(tokens[start])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previous = tokens[start - 1];
|
||||
// At line start if nothing precedes this image, or the preceding token ends with only
|
||||
// whitespace since the last newline (covers a bare space between two images on the same line).
|
||||
const atLineStart =
|
||||
previous === undefined ||
|
||||
(previous.kind === "text" && /(?:^|[\n\r])[ \t]*$/.test(previous.value));
|
||||
if (!atLineStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scan forward past same-line whitespace-only text tokens and inline images to find
|
||||
// the first substantive text token, without crossing a newline.
|
||||
let cursor = end;
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor];
|
||||
if (token === undefined) {
|
||||
break;
|
||||
}
|
||||
if (token.kind === "text") {
|
||||
const sameLine = token.value.split(/\r?\n/, 1)[0] ?? "";
|
||||
if (sameLine.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
// Whitespace-only on this line — keep scanning only if no newline was crossed.
|
||||
if (token.value.includes("\n") || token.value.includes("\r")) {
|
||||
return false;
|
||||
}
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
// An inline image tag — skip over it (the image itself and its possible wrapping close tag).
|
||||
if (token.kind === "tag") {
|
||||
if (isImageBlockWrapper(token)) {
|
||||
return false;
|
||||
}
|
||||
const imageResult = parseInlineImageAt(tokens, cursor);
|
||||
if (imageResult) {
|
||||
cursor = imageResult.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (
|
||||
isOpenTag(tokens[index], "details") ||
|
||||
findMixedHeadingClose(tokens, index) !== null ||
|
||||
parseInlineImageAt(tokens, index)
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null {
|
||||
const token = tokens[start];
|
||||
if (!isHeadingTag(token)) {
|
||||
return null;
|
||||
}
|
||||
const closeIndex = findMatchingClose(tokens, start, token.name);
|
||||
if (closeIndex === null) {
|
||||
return null;
|
||||
}
|
||||
for (let index = start + 1; index < closeIndex; index += 1) {
|
||||
if (parseInlineImageAt(tokens, index)) {
|
||||
return closeIndex;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void {
|
||||
if (part.kind === "markdown") {
|
||||
appendMarkdownPart(parts, part.text);
|
||||
} else {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const previous = parts.at(-1);
|
||||
if (previous?.kind === "markdown") {
|
||||
previous.text += text;
|
||||
return;
|
||||
}
|
||||
parts.push({ kind: "markdown", text });
|
||||
}
|
||||
|
||||
function parseDetailsTokens(tokens: HtmlToken[]): MarkdownDetailsPart | null {
|
||||
const summaryOpenIndex = findNextOpenTag(tokens, 0, "summary");
|
||||
if (summaryOpenIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryCloseIndex = findMatchingClose(tokens, summaryOpenIndex, "summary");
|
||||
if (summaryCloseIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryTokens = tokens.slice(summaryOpenIndex + 1, summaryCloseIndex);
|
||||
const bodyTokens = [...tokens.slice(0, summaryOpenIndex), ...tokens.slice(summaryCloseIndex + 1)];
|
||||
const summary = renderSummaryTokens(summaryTokens).trim();
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bodyParts = splitHtmlishTokens(bodyTokens);
|
||||
const body = renderBodyText(bodyParts);
|
||||
|
||||
return {
|
||||
kind: "details",
|
||||
summary,
|
||||
body: body.trim(),
|
||||
...(bodyParts.some((part) => part.kind !== "markdown")
|
||||
? { bodyParts: trimBodyParts(bodyParts) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function renderBodyText(parts: MarkdownDisplayPart[]): string {
|
||||
return parts.map((part) => (part.kind === "markdown" ? part.text : "")).join("");
|
||||
}
|
||||
|
||||
function trimBodyParts(parts: MarkdownDisplayPart[]): MarkdownDisplayPart[] {
|
||||
const trimmed = [...parts];
|
||||
const first = trimmed[0];
|
||||
if (first?.kind === "markdown") {
|
||||
first.text = first.text.trimStart();
|
||||
}
|
||||
const last = trimmed.at(-1);
|
||||
if (last?.kind === "markdown") {
|
||||
last.text = last.text.trimEnd();
|
||||
}
|
||||
return trimmed.filter((part) => part.kind !== "markdown" || part.text.length > 0);
|
||||
}
|
||||
|
||||
function renderSummaryTokens(tokens: HtmlToken[]): string {
|
||||
return stripSingleHeadingWrapper(renderInlineTokens(tokens));
|
||||
}
|
||||
|
||||
function stripSingleHeadingWrapper(text: string): string {
|
||||
const tokens = tokenizeHtmlishMarkdown(text.trim());
|
||||
if (tokens.length < 3) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const first = tokens[0];
|
||||
const last = tokens.at(-1);
|
||||
if (!isHeadingTag(first) || !isClosingTag(last, first.name)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return renderInlineTokens(tokens.slice(1, -1));
|
||||
}
|
||||
|
||||
function renderInlineTokens(tokens: HtmlToken[]): string {
|
||||
let output = "";
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "text") {
|
||||
output += token.value;
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "comment" || token.closing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.name === "br") {
|
||||
output += "\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.name === "img") {
|
||||
output += renderImageToken(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeIndex = token.selfClosing ? null : findMatchingClose(tokens, index, token.name);
|
||||
if (closeIndex === null) {
|
||||
output += renderUnknownTag(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const children = tokens.slice(index + 1, closeIndex);
|
||||
const close = tokens[closeIndex];
|
||||
const closeRaw = isClosingTag(close, token.name) ? close.raw : `</${token.name}>`;
|
||||
output += renderHtmlTag(token, children, closeRaw);
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: string): string {
|
||||
if (token.name === "a") {
|
||||
return renderLinkToken(token, children);
|
||||
}
|
||||
if (token.name === "sub" || isHeadingTagName(token.name)) {
|
||||
return renderInlineTokens(children);
|
||||
}
|
||||
if (token.name === "code" && children.every((child) => child.kind === "text")) {
|
||||
return `\`${renderInlineTokens(children)}\``;
|
||||
}
|
||||
const wrapper = MARKDOWN_TAG_WRAPPERS[token.name];
|
||||
if (wrapper) {
|
||||
return `${wrapper[0]}${renderInlineTokens(children)}${wrapper[1]}`;
|
||||
}
|
||||
if (token.name === "table") {
|
||||
return renderTableTokens(children);
|
||||
}
|
||||
if (token.name === "p" || token.name === "div") {
|
||||
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
|
||||
}
|
||||
return `${token.raw}${renderInlineTokens(children)}${closeRaw}`;
|
||||
}
|
||||
|
||||
function renderTableTokens(tokens: HtmlToken[]): string {
|
||||
const rows: string[] = [];
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
if (!isOpenTag(tokens[index], "tr")) {
|
||||
continue;
|
||||
}
|
||||
const closeIndex = findMatchingClose(tokens, index, "tr");
|
||||
if (closeIndex === null) {
|
||||
continue;
|
||||
}
|
||||
const cells = renderTableCells(tokens.slice(index + 1, closeIndex));
|
||||
if (cells.length === 1) {
|
||||
rows.push(`- ${cells[0]}`);
|
||||
} else if (cells.length > 1) {
|
||||
const label =
|
||||
cells[0].startsWith("**") && cells[0].endsWith("**") ? cells[0] : `**${cells[0]}**`;
|
||||
rows.push(`- ${label}: ${cells.slice(1).join(" ")}`);
|
||||
}
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return rows.length > 0 ? `\n${rows.join("\n")}\n` : "";
|
||||
}
|
||||
|
||||
function renderTableCells(tokens: HtmlToken[]): string[] {
|
||||
const cells: string[] = [];
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (!isOpenTag(token, "td") && !isOpenTag(token, "th")) {
|
||||
continue;
|
||||
}
|
||||
const closeIndex = findMatchingClose(tokens, index, token.name);
|
||||
if (closeIndex === null) {
|
||||
continue;
|
||||
}
|
||||
const cell = renderInlineTokens(tokens.slice(index + 1, closeIndex)).trim();
|
||||
if (cell) {
|
||||
cells.push(cell);
|
||||
}
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
function renderImageToken(token: HtmlTagToken): string {
|
||||
const image = imageTokenToInlineImage(token, undefined);
|
||||
if (!image) {
|
||||
return token.raw;
|
||||
}
|
||||
|
||||
return ``;
|
||||
}
|
||||
|
||||
function renderLinkToken(token: HtmlTagToken, children: HtmlToken[]): string {
|
||||
const imageOnly = getSingleImageChild(children);
|
||||
if (imageOnly) {
|
||||
return renderImageToken(imageOnly);
|
||||
}
|
||||
|
||||
const label = renderInlineTokens(children);
|
||||
const href = token.attributes.href ?? "";
|
||||
if (!label || !SAFE_LINK_HREF_RE.test(href) || href === "#") {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `[${label}](${href})`;
|
||||
}
|
||||
|
||||
function imageTokenToInlineImage(
|
||||
token: HtmlTagToken,
|
||||
href: string | undefined,
|
||||
): MarkdownInlineImagePart | null {
|
||||
const src = token.attributes.src ?? "";
|
||||
if (!SAFE_IMAGE_SRC_RE.test(src)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "inlineImage",
|
||||
alt: token.attributes.alt ?? "",
|
||||
src,
|
||||
...(href ? { href } : {}),
|
||||
...parseImageDimensions(token.attributes),
|
||||
};
|
||||
}
|
||||
|
||||
function parseImageDimensions(attributes: Record<string, string>): MarkdownImageDimensions {
|
||||
return {
|
||||
...parseImageDimension("width", attributes.width),
|
||||
...parseImageDimension("height", attributes.height),
|
||||
};
|
||||
}
|
||||
|
||||
function parseImageDimension(key: "width" | "height", value: string | undefined) {
|
||||
if (!value || !/^\d+(?:\.\d+)?$/.test(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? { [key]: parsed } : {};
|
||||
}
|
||||
|
||||
function safeHref(href: string | undefined): string | undefined {
|
||||
if (!href || href === "#" || !SAFE_LINK_HREF_RE.test(href)) {
|
||||
return undefined;
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
function getSingleImageChild(tokens: HtmlToken[]): HtmlTagToken | null {
|
||||
const visible = tokens.filter((token) => token.kind !== "comment" && !isWhitespaceText(token));
|
||||
return visible.length === 1 && isOpenTag(visible[0], "img") ? visible[0] : null;
|
||||
}
|
||||
|
||||
function renderUnknownTag(token: HtmlTagToken): string {
|
||||
if (token.name === "script" || token.name === "style") {
|
||||
return "";
|
||||
}
|
||||
return token.raw;
|
||||
}
|
||||
|
||||
function escapeMarkdownImageAlt(value: string): string {
|
||||
return value.replace(/]/g, "\\]");
|
||||
}
|
||||
|
||||
function findNextOpenTag(tokens: HtmlToken[], start: number, name: string): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (isOpenTag(tokens[index], name)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMatchingClose(tokens: HtmlToken[], openIndex: number, name: string): number | null {
|
||||
let depth = 1;
|
||||
for (let index = openIndex + 1; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (isOpenTag(token, name) && !token.selfClosing) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (isClosingTag(token, name)) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isOpenTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && token.name === name && !token.closing;
|
||||
}
|
||||
|
||||
function isClosingTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && token.name === name && token.closing;
|
||||
}
|
||||
|
||||
function isHeadingTag(token: HtmlToken | undefined): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && isHeadingTagName(token.name) && !token.closing;
|
||||
}
|
||||
|
||||
function isHeadingTagName(name: string): boolean {
|
||||
return (
|
||||
name === "h1" ||
|
||||
name === "h2" ||
|
||||
name === "h3" ||
|
||||
name === "h4" ||
|
||||
name === "h5" ||
|
||||
name === "h6"
|
||||
);
|
||||
}
|
||||
|
||||
function isWhitespaceText(token: HtmlToken): boolean {
|
||||
return token.kind === "text" && token.value.trim() === "";
|
||||
}
|
||||
|
||||
function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
|
||||
const protectedRanges = getProtectedMarkdownRanges(source);
|
||||
const tokens: HtmlToken[] = [];
|
||||
const parser = createHtmlishTokenParser(source, tokens);
|
||||
let cursor = 0;
|
||||
|
||||
for (const range of protectedRanges) {
|
||||
parser.write(source.slice(cursor, range.start));
|
||||
tokens.push({ kind: "text", value: source.slice(range.start, range.end) });
|
||||
parser.skip(range.end - range.start);
|
||||
cursor = range.end;
|
||||
}
|
||||
|
||||
parser.write(source.slice(cursor));
|
||||
parser.end();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
|
||||
let stripNextLeadingLineBreak = false;
|
||||
let skippedLength = 0;
|
||||
let writtenLength = 0;
|
||||
let chunkStart = 0;
|
||||
let parser!: Parser;
|
||||
// Unknown tags render as text, so keep them as written: the parser lowercases names
|
||||
// and rewrites attributes. A tag split by a protected range has no single slice.
|
||||
const readRawTag = (): string | null =>
|
||||
parser.startIndex < chunkStart
|
||||
? null
|
||||
: source.slice(parser.startIndex + skippedLength, parser.endIndex + 1 + skippedLength);
|
||||
parser = new Parser(
|
||||
{
|
||||
onopentag(name, attributes, isImplied) {
|
||||
if (isImplied) {
|
||||
return;
|
||||
}
|
||||
const selfClosing = VOID_HTML_TAGS.has(name);
|
||||
tokens.push({
|
||||
kind: "tag",
|
||||
name,
|
||||
closing: false,
|
||||
selfClosing,
|
||||
attributes,
|
||||
raw: readRawTag() ?? renderStartTag(name, attributes),
|
||||
});
|
||||
},
|
||||
onclosetag(name, isImplied) {
|
||||
if (isImplied || VOID_HTML_TAGS.has(name)) {
|
||||
return;
|
||||
}
|
||||
tokens.push({
|
||||
kind: "tag",
|
||||
name,
|
||||
closing: true,
|
||||
selfClosing: false,
|
||||
attributes: {},
|
||||
raw: readRawTag() ?? `</${name}>`,
|
||||
});
|
||||
},
|
||||
ontext(value) {
|
||||
if (stripNextLeadingLineBreak) {
|
||||
stripNextLeadingLineBreak = false;
|
||||
value = stripLeadingLineBreak(value);
|
||||
}
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
tokens.push({ kind: "text", value });
|
||||
},
|
||||
oncomment() {
|
||||
stripNextLeadingLineBreak = isLineStart(source, parser.startIndex + skippedLength);
|
||||
tokens.push({ kind: "comment" });
|
||||
},
|
||||
},
|
||||
{
|
||||
decodeEntities: false,
|
||||
lowerCaseAttributeNames: true,
|
||||
lowerCaseTags: true,
|
||||
recognizeSelfClosing: true,
|
||||
},
|
||||
);
|
||||
return {
|
||||
write(chunk) {
|
||||
chunkStart = writtenLength;
|
||||
writtenLength += chunk.length;
|
||||
parser.write(chunk);
|
||||
},
|
||||
skip(length) {
|
||||
skippedLength += length;
|
||||
},
|
||||
end() {
|
||||
parser.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isLineStart(source: string, index: number): boolean {
|
||||
return index === 0 || source[index - 1] === "\n" || source[index - 1] === "\r";
|
||||
}
|
||||
|
||||
function stripLeadingLineBreak(value: string): string {
|
||||
if (value.startsWith("\r\n")) {
|
||||
return value.slice(2);
|
||||
}
|
||||
if (value.startsWith("\n") || value.startsWith("\r")) {
|
||||
return value.slice(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderStartTag(name: string, attributes: Record<string, string>): string {
|
||||
const renderedAttributes = Object.entries(attributes)
|
||||
.map(([key, value]) => (value === "" ? ` ${key}` : ` ${key}="${escapeAttribute(value)}"`))
|
||||
.join("");
|
||||
return `<${name}${renderedAttributes}>`;
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll('"', """);
|
||||
}
|
||||
|
||||
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
|
||||
const fencedRanges = getFencedCodeRanges(source);
|
||||
const codeRanges = mergeProtectedRanges([
|
||||
...fencedRanges,
|
||||
...getInlineCodeRanges(source, fencedRanges),
|
||||
]);
|
||||
return mergeProtectedRanges([...codeRanges, ...getLiteralAngleBracketRanges(source, codeRanges)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* htmlparser2 reads any `<` followed by a letter as a tag and keeps collecting attributes up
|
||||
* to the next `>`, so `1<n<2` in a table cell swallows the rows after it and an autolink loses
|
||||
* its slashes. Hand it only what CommonMark would read as raw HTML; every other `<` is text.
|
||||
*/
|
||||
function getLiteralAngleBracketRanges(
|
||||
source: string,
|
||||
codeRanges: ProtectedMarkdownRange[],
|
||||
): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
let codeIndex = 0;
|
||||
// Reused across comment openers so unclosed comments stay linear.
|
||||
let commentClose = -1;
|
||||
let index = source.indexOf("<");
|
||||
|
||||
while (index !== -1) {
|
||||
while ((codeRanges[codeIndex]?.end ?? Infinity) <= index) {
|
||||
codeIndex += 1;
|
||||
}
|
||||
const code = codeRanges[codeIndex];
|
||||
if (code && code.start <= index) {
|
||||
index = source.indexOf("<", code.end);
|
||||
continue;
|
||||
}
|
||||
|
||||
let htmlEnd: number | null;
|
||||
if (source.startsWith("<!--", index)) {
|
||||
if (commentClose !== source.length && commentClose < index + 2) {
|
||||
const close = source.indexOf("-->", index + 2);
|
||||
commentClose = close === -1 ? source.length : close;
|
||||
}
|
||||
htmlEnd = commentClose === source.length ? null : commentClose + 3;
|
||||
} else {
|
||||
HTML_TAG_RE.lastIndex = index;
|
||||
htmlEnd = HTML_TAG_RE.test(source) ? HTML_TAG_RE.lastIndex : null;
|
||||
}
|
||||
|
||||
if (htmlEnd === null) {
|
||||
ranges.push({ start: index, end: index + 1 });
|
||||
htmlEnd = index + 1;
|
||||
}
|
||||
index = source.indexOf("<", htmlEnd);
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
FENCE_LINE_RE.lastIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const open = FENCE_LINE_RE.exec(source);
|
||||
if (!open) {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
const marker = open[1];
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const close = findClosingFence(source, FENCE_LINE_RE.lastIndex, marker);
|
||||
if (!close) {
|
||||
ranges.push({ start: open.index, end: source.length });
|
||||
return ranges;
|
||||
}
|
||||
|
||||
ranges.push({ start: open.index, end: close.end });
|
||||
FENCE_LINE_RE.lastIndex = close.end;
|
||||
}
|
||||
}
|
||||
|
||||
function findClosingFence(
|
||||
source: string,
|
||||
start: number,
|
||||
marker: string,
|
||||
): MarkdownDelimiterMatch | null {
|
||||
const closeRe = new RegExp(
|
||||
`^ {0,3}[${marker[0]}]{${marker.length},}[^\\n\\r]*(?:\\r?\\n|$)`,
|
||||
"gm",
|
||||
);
|
||||
closeRe.lastIndex = start;
|
||||
const close = closeRe.exec(source);
|
||||
return close ? { index: close.index, end: closeRe.lastIndex } : null;
|
||||
}
|
||||
|
||||
function getInlineCodeRanges(
|
||||
source: string,
|
||||
fencedRanges: ProtectedMarkdownRange[],
|
||||
): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
BACKTICK_RUN_RE.lastIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const open = BACKTICK_RUN_RE.exec(source);
|
||||
if (!open) {
|
||||
return ranges;
|
||||
}
|
||||
if (isProtectedIndex(open.index, fencedRanges)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = open[0];
|
||||
const afterOpen = BACKTICK_RUN_RE.lastIndex;
|
||||
const close = findClosingBacktickRun(source, afterOpen, marker, fencedRanges);
|
||||
if (!close) {
|
||||
// Unmatched backtick run — skip past it so the loop doesn't restart from 0.
|
||||
// findClosingBacktickRun exhausts the global regex, which resets lastIndex
|
||||
// to 0 when exec() returns null (ECMAScript §22.2.7.2).
|
||||
BACKTICK_RUN_RE.lastIndex = afterOpen;
|
||||
continue;
|
||||
}
|
||||
|
||||
ranges.push({ start: open.index, end: close.end });
|
||||
BACKTICK_RUN_RE.lastIndex = close.end;
|
||||
}
|
||||
}
|
||||
|
||||
function findClosingBacktickRun(
|
||||
source: string,
|
||||
start: number,
|
||||
marker: string,
|
||||
fencedRanges: ProtectedMarkdownRange[],
|
||||
): MarkdownDelimiterMatch | null {
|
||||
BACKTICK_RUN_RE.lastIndex = start;
|
||||
|
||||
while (true) {
|
||||
const close = BACKTICK_RUN_RE.exec(source);
|
||||
if (!close) {
|
||||
return null;
|
||||
}
|
||||
if (close[0] === marker && !isProtectedIndex(close.index, fencedRanges)) {
|
||||
return { index: close.index, end: BACKTICK_RUN_RE.lastIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProtectedRanges(ranges: ProtectedMarkdownRange[]): ProtectedMarkdownRange[] {
|
||||
const sorted = [...ranges].sort((a, b) => a.start - b.start);
|
||||
const merged: ProtectedMarkdownRange[] = [];
|
||||
|
||||
for (const range of sorted) {
|
||||
const previous = merged.at(-1);
|
||||
if (!previous || range.start > previous.end) {
|
||||
merged.push({ ...range });
|
||||
continue;
|
||||
}
|
||||
previous.end = Math.max(previous.end, range.end);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isProtectedIndex(index: number, ranges: ProtectedMarkdownRange[]): boolean {
|
||||
return ranges.some((range) => index >= range.start && index < range.end);
|
||||
}
|
||||
|
||||
export function normalizeHtmlishMarkdown(source: string): string {
|
||||
return renderInlineTokens(tokenizeHtmlishMarkdown(source));
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo, useEffect } from "react";
|
||||
import { useFetchQueries } from "@/data/query";
|
||||
import { createAssistantImageFileAcquisition } from "@/assistant-image/file-acquisition";
|
||||
import {
|
||||
persistAttachmentFromBytes,
|
||||
resolveAttachmentPreviewUrl,
|
||||
releaseAttachmentPreviewUrl,
|
||||
} from "@/attachments/service";
|
||||
import { createAssistantImageAcquisitionCache } from "@/assistant-image/acquisition-cache";
|
||||
import { retainAttachmentForGarbageCollection } from "@/attachments/gc-retention";
|
||||
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { resolveMarkdownPreviewImageSource } from "./image-source";
|
||||
import { prepareMarkdownSource } from "./source";
|
||||
|
||||
const urls = createAssistantImageAcquisitionCache<{ uri: string; attachment: AttachmentMetadata }>({
|
||||
capacity: 500,
|
||||
onRetain: ({ uri, attachment }) => {
|
||||
const release = retainAttachmentForGarbageCollection(attachment.id);
|
||||
return () => {
|
||||
release();
|
||||
void releaseAttachmentPreviewUrl({ attachment, url: uri });
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export interface MarkdownImageContext {
|
||||
client?: Pick<DaemonClient, "readFile"> | null;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
|
||||
const prepared = useMemo(() => prepareMarkdownSource(text), [text]);
|
||||
const sources = [...new Set(prepared.images.map((image) => image.source))];
|
||||
const acquisitions = sources.map((source) => {
|
||||
const resolved = context.filePath
|
||||
? resolveMarkdownPreviewImageSource({
|
||||
source,
|
||||
markdownPath: context.filePath,
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
})
|
||||
: source;
|
||||
return createAssistantImageFileAcquisition({
|
||||
port: context.client
|
||||
? {
|
||||
readFile: (cwd, path) => context.client!.readFile(cwd, path),
|
||||
persist: persistAttachmentFromBytes,
|
||||
}
|
||||
: null,
|
||||
resolution: resolved
|
||||
? resolveAssistantImageSource({ source: resolved, workspaceRoot: context.workspaceRoot })
|
||||
: null,
|
||||
serverId: context.serverId,
|
||||
occurrenceKey: context.filePath ?? context.workspaceRoot ?? "markdown",
|
||||
unavailableMessage: "Image unavailable",
|
||||
});
|
||||
});
|
||||
const results = useFetchQueries(
|
||||
acquisitions.map((acquisition, index) => ({
|
||||
queryKey: ["markdownImage", acquisition?.key ?? sources[index]],
|
||||
enabled: acquisition !== null,
|
||||
immutableWhen: () => true,
|
||||
dataShape: "value",
|
||||
gcTime: 0,
|
||||
queryFn: async () => {
|
||||
if (!acquisition) throw new Error("No image acquisition");
|
||||
return urls.acquire(acquisition.key, async () => {
|
||||
const attachment = await acquisition.locate();
|
||||
const uri = await resolveAttachmentPreviewUrl(attachment);
|
||||
return { attachment, uri };
|
||||
});
|
||||
},
|
||||
})),
|
||||
);
|
||||
useEffect(() => {
|
||||
const releases = results.flatMap((result, index) => {
|
||||
const acquisition = acquisitions[index];
|
||||
if (!result.data || !acquisition) return [];
|
||||
const value = result.data;
|
||||
return [urls.acquireRetained(acquisition.key, async () => value).release];
|
||||
});
|
||||
return () => releases.forEach((release) => release());
|
||||
}, [results, acquisitions]);
|
||||
const resolved = new Map<string, string>();
|
||||
results.forEach((result, index) => {
|
||||
if (result.data) resolved.set(sources[index]!, result.data.uri);
|
||||
});
|
||||
return {
|
||||
markdown: prepared.render(resolved),
|
||||
imageSources: Object.fromEntries([...resolved].map(([source, url]) => [url, source])),
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
export interface InlineImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface InlineImageExplicitDimensions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const INLINE_IMAGE_MAX_WIDTH = 240;
|
||||
const INLINE_IMAGE_MAX_HEIGHT = 160;
|
||||
|
||||
export function resolveInlineImageSize(input: {
|
||||
explicit: InlineImageExplicitDimensions;
|
||||
natural: InlineImageDimensions | null;
|
||||
}): InlineImageDimensions {
|
||||
const dimensions = resolveInlineImageDimensions(input);
|
||||
const scale = Math.min(
|
||||
1,
|
||||
INLINE_IMAGE_MAX_WIDTH / dimensions.width,
|
||||
INLINE_IMAGE_MAX_HEIGHT / dimensions.height,
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.round(dimensions.width * scale),
|
||||
height: Math.round(dimensions.height * scale),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInlineImageDimensions(input: {
|
||||
explicit: InlineImageExplicitDimensions;
|
||||
natural: InlineImageDimensions | null;
|
||||
}): InlineImageDimensions {
|
||||
if (input.explicit.width && input.explicit.height) {
|
||||
return { width: input.explicit.width, height: input.explicit.height };
|
||||
}
|
||||
|
||||
if (input.explicit.width && input.natural) {
|
||||
return {
|
||||
width: input.explicit.width,
|
||||
height: input.explicit.width * (input.natural.height / input.natural.width),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.explicit.height && input.natural) {
|
||||
return {
|
||||
width: input.explicit.height * (input.natural.width / input.natural.height),
|
||||
height: input.explicit.height,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.explicit.width) {
|
||||
return { width: input.explicit.width, height: input.explicit.width };
|
||||
}
|
||||
|
||||
if (input.explicit.height) {
|
||||
return { width: input.explicit.height, height: input.explicit.height };
|
||||
}
|
||||
|
||||
return input.natural ?? { width: INLINE_IMAGE_MAX_WIDTH, height: INLINE_IMAGE_MAX_HEIGHT };
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Children, cloneElement, isValidElement, type ReactNode } from "react";
|
||||
import type { StyleProp, TextStyle } from "react-native";
|
||||
|
||||
export function colorMarkdownLinkChildren(
|
||||
children: ReactNode,
|
||||
color: TextStyle["color"],
|
||||
): ReactNode {
|
||||
return Children.map(children, (child) => {
|
||||
if (!isValidElement<{ style?: StyleProp<TextStyle> }>(child)) return child;
|
||||
return cloneElement(child, { style: [child.props.style, { color }] });
|
||||
});
|
||||
}
|
||||
|
||||
const MARKDOWN_LINK_HOVER_STYLE: TextStyle = { textDecorationLine: "underline" };
|
||||
|
||||
export function markdownLinkTextStyle(
|
||||
style: StyleProp<TextStyle>,
|
||||
hovered: boolean,
|
||||
): StyleProp<TextStyle> {
|
||||
return [style, hovered && MARKDOWN_LINK_HOVER_STYLE];
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { Pressable, Text, type StyleProp, type TextStyle } from "react-native";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { markdownLinkTextStyle } from "./link-children";
|
||||
|
||||
interface MarkdownLinkTextProps {
|
||||
style: StyleProp<TextStyle>;
|
||||
dataSet?: Record<string, string>;
|
||||
onPress(): void;
|
||||
onHoverIn?(): void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function MarkdownLinkText({
|
||||
style,
|
||||
dataSet,
|
||||
onPress,
|
||||
onHoverIn,
|
||||
children,
|
||||
}: MarkdownLinkTextProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const handleHoverIn = useStableEvent(() => {
|
||||
setHovered(true);
|
||||
onHoverIn?.();
|
||||
});
|
||||
const handleHoverOut = useStableEvent(() => setHovered(false));
|
||||
const textStyle = useMemo(() => markdownLinkTextStyle(style, hovered), [hovered, style]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={onPress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
<Text dataSet={dataSet} style={textStyle}>
|
||||
{children}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MarkdownDisplayPart } from "./html-ish";
|
||||
import { groupMarkdownParts } from "./part-groups";
|
||||
|
||||
describe("groupMarkdownParts", () => {
|
||||
const flowImage: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/priority.svg",
|
||||
flowsWithText: true,
|
||||
};
|
||||
|
||||
it("flows an image with the lead paragraph and keeps later paragraphs full width", () => {
|
||||
expect(
|
||||
groupMarkdownParts([
|
||||
flowImage,
|
||||
{ kind: "markdown", text: " **Title line**\n\nSecond paragraph." },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "imageText",
|
||||
images: [flowImage],
|
||||
lead: "**Title line**",
|
||||
rest: "Second paragraph.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flows an image with a single-paragraph text", () => {
|
||||
expect(groupMarkdownParts([flowImage, { kind: "markdown", text: " Only line" }])).toEqual([
|
||||
{ kind: "imageText", images: [flowImage], lead: "Only line", rest: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups two consecutive flowing images with the lead paragraph", () => {
|
||||
const secondImage: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Severity",
|
||||
src: "https://example.com/severity.svg",
|
||||
flowsWithText: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
groupMarkdownParts([
|
||||
flowImage,
|
||||
{ kind: "markdown", text: " " },
|
||||
secondImage,
|
||||
{ kind: "markdown", text: " **Title text here**\n\nBody paragraph." },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "imageText",
|
||||
images: [flowImage, secondImage],
|
||||
lead: "**Title text here**",
|
||||
rest: "Body paragraph.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps standalone images as plain parts", () => {
|
||||
const image: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Shot",
|
||||
src: "https://example.com/shot.png",
|
||||
};
|
||||
|
||||
expect(groupMarkdownParts([image, { kind: "markdown", text: "\n\nCaption" }])).toEqual([
|
||||
{ kind: "part", part: image },
|
||||
{ kind: "part", part: { kind: "markdown", text: "\n\nCaption" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a flowing image alone when no markdown follows", () => {
|
||||
expect(groupMarkdownParts([flowImage])).toEqual([{ kind: "part", part: flowImage }]);
|
||||
});
|
||||
|
||||
it("keeps a flowing image alone when the following markdown starts with a blank line", () => {
|
||||
expect(groupMarkdownParts([flowImage, { kind: "markdown", text: "\n\nBelow" }])).toEqual([
|
||||
{ kind: "part", part: flowImage },
|
||||
{ kind: "part", part: { kind: "markdown", text: "\n\nBelow" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes other part kinds through unchanged", () => {
|
||||
const details: MarkdownDisplayPart = { kind: "details", summary: "More", body: "Body" };
|
||||
|
||||
expect(groupMarkdownParts([{ kind: "markdown", text: "Intro" }, details])).toEqual([
|
||||
{ kind: "part", part: { kind: "markdown", text: "Intro" } },
|
||||
{ kind: "part", part: details },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { MarkdownDisplayPart, MarkdownInlineImagePart } from "./html-ish";
|
||||
|
||||
export type MarkdownPartGroup =
|
||||
| { kind: "part"; part: MarkdownDisplayPart }
|
||||
| { kind: "imageText"; images: MarkdownInlineImagePart[]; lead: string; rest: string };
|
||||
|
||||
/**
|
||||
* Pairs a run of inline images that flow with text (small status images at the
|
||||
* start of a line, GitHub-style) with the lead paragraph of the following
|
||||
* markdown part, so the renderer can lay them out side by side instead of
|
||||
* stacking the images as their own blocks.
|
||||
*
|
||||
* Between consecutive flowing images the parser may emit whitespace-only
|
||||
* markdown parts — these are consumed and discarded when building the group.
|
||||
*/
|
||||
export function groupMarkdownParts(parts: readonly MarkdownDisplayPart[]): MarkdownPartGroup[] {
|
||||
const groups: MarkdownPartGroup[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < parts.length) {
|
||||
const part = parts[index];
|
||||
|
||||
if (part.kind === "inlineImage" && part.flowsWithText) {
|
||||
// Collect the maximal run of flowing images, skipping whitespace-only markdown between them.
|
||||
const images: MarkdownInlineImagePart[] = [part];
|
||||
let lookahead = index + 1;
|
||||
|
||||
while (lookahead < parts.length) {
|
||||
const candidate = parts[lookahead];
|
||||
if (candidate === undefined) {
|
||||
break;
|
||||
}
|
||||
if (isWhitespaceOnlyMarkdown(candidate)) {
|
||||
lookahead += 1;
|
||||
continue;
|
||||
}
|
||||
if (candidate.kind === "inlineImage" && candidate.flowsWithText) {
|
||||
images.push(candidate);
|
||||
lookahead += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const trailing = parts[lookahead];
|
||||
if (trailing?.kind === "markdown") {
|
||||
const { lead, rest } = splitLeadParagraph(trailing.text);
|
||||
if (lead) {
|
||||
groups.push({ kind: "imageText", images, lead, rest });
|
||||
index = lookahead + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No usable lead — emit the images (and any whitespace parts) as plain parts.
|
||||
for (let i = index; i < lookahead; i += 1) {
|
||||
const p = parts[i];
|
||||
if (p !== undefined) {
|
||||
groups.push({ kind: "part", part: p });
|
||||
}
|
||||
}
|
||||
index = lookahead;
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push({ kind: "part", part });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function isWhitespaceOnlyMarkdown(part: MarkdownDisplayPart): boolean {
|
||||
return part.kind === "markdown" && part.text.trim() === "";
|
||||
}
|
||||
|
||||
function splitLeadParagraph(text: string): { lead: string; rest: string } {
|
||||
if (/^[ \t]*\r?\n/.test(text)) {
|
||||
return { lead: "", rest: text };
|
||||
}
|
||||
const boundary = /\r?\n[ \t]*\r?\n/.exec(text);
|
||||
if (!boundary) {
|
||||
return { lead: text.trim(), rest: "" };
|
||||
}
|
||||
return {
|
||||
lead: text.slice(0, boundary.index).trim(),
|
||||
rest: text.slice(boundary.index + boundary[0].length),
|
||||
};
|
||||
}
|
||||
@@ -1,99 +1,38 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { Text, type StyleProp, type TextStyle } from "react-native";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveInlineImageSize } from "./inline-image-size";
|
||||
import { colorMarkdownLinkChildren } from "./link-children";
|
||||
import { MarkdownLinkText } from "./link-text";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { prepareMarkdownSource } from "./source";
|
||||
|
||||
vi.stubGlobal("React", React);
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
Pressable: ({
|
||||
accessibilityRole,
|
||||
children,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
onPress,
|
||||
}: {
|
||||
accessibilityRole?: string;
|
||||
children?: ReactNode;
|
||||
onHoverIn?(): void;
|
||||
onHoverOut?(): void;
|
||||
onPress?(): void;
|
||||
}) =>
|
||||
createElement(
|
||||
"div",
|
||||
{
|
||||
role: accessibilityRole,
|
||||
onClick: onPress,
|
||||
onMouseEnter: onHoverIn,
|
||||
onMouseLeave: onHoverOut,
|
||||
},
|
||||
children,
|
||||
),
|
||||
Text: ({ children, style }: { children?: ReactNode; style?: StyleProp<TextStyle> }) =>
|
||||
createElement("span", { style: flattenStyle(style) }, children),
|
||||
}));
|
||||
|
||||
function flattenStyle(style: StyleProp<TextStyle>): TextStyle {
|
||||
return Object.assign({}, ...(Array.isArray(style) ? style.filter(Boolean) : [style]));
|
||||
}
|
||||
|
||||
describe("resolveInlineImageSize", () => {
|
||||
it("respects a one-sided explicit width using natural aspect ratio", () => {
|
||||
expect(
|
||||
resolveInlineImageSize({ explicit: { width: 18 }, natural: { width: 90, height: 45 } }),
|
||||
).toEqual({
|
||||
width: 18,
|
||||
height: 9,
|
||||
});
|
||||
describe("shared Markdown source adaptation", () => {
|
||||
it("keeps paragraphs, fences and quotes together for the library", () => {
|
||||
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
||||
expect(prepareMarkdownSource(source).render()).toBe(source);
|
||||
});
|
||||
|
||||
it("respects a one-sided explicit height using natural aspect ratio", () => {
|
||||
expect(
|
||||
resolveInlineImageSize({ explicit: { height: 18 }, natural: { width: 90, height: 45 } }),
|
||||
).toEqual({
|
||||
width: 36,
|
||||
height: 18,
|
||||
});
|
||||
it("resolves workspace images without rewriting code or remote image URLs", () => {
|
||||
const source =
|
||||
"\n\n\n\n```md\n\n```";
|
||||
const prepared = prepareMarkdownSource(source);
|
||||
expect(prepared.images.map((image) => image.source)).toEqual([
|
||||
"assets/a.png",
|
||||
"https://example.com/a.png",
|
||||
]);
|
||||
expect(prepared.render(new Map([["assets/a.png", "blob:cached"]]))).toBe(
|
||||
"\n\n\n\n```md\n\n```",
|
||||
);
|
||||
});
|
||||
|
||||
it("reserves a capped 3:2 placeholder when no dimensions are known", () => {
|
||||
expect(resolveInlineImageSize({ explicit: {}, natural: null })).toEqual({
|
||||
width: 240,
|
||||
height: 160,
|
||||
});
|
||||
it("resolves reference images and images nested in lists and quotes", () => {
|
||||
const prepared = prepareMarkdownSource("> - ![Image][picture]\n\n[picture]: ../a.png");
|
||||
expect(prepared.render(new Map([["../a.png", "file:///cache/a.png"]]))).toBe(
|
||||
"> - \n\n[picture]: ../a.png",
|
||||
);
|
||||
});
|
||||
it("escapes spoiler delimiters only in prose", () => {
|
||||
expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe(
|
||||
"Show \\|\\|words\\|\\| and `||code||`.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared Markdown links", () => {
|
||||
it("renders accent text and underlines it while hovered", () => {
|
||||
const onPress = vi.fn();
|
||||
const children = colorMarkdownLinkChildren(
|
||||
createElement(Text, { style: { color: "white" } }, "Paseo"),
|
||||
"rgb(0, 122, 255)",
|
||||
);
|
||||
const view = render(
|
||||
createElement(MarkdownLinkText, { style: { color: "rgb(0, 122, 255)" }, onPress }, children),
|
||||
);
|
||||
const link = view.getByRole("link");
|
||||
const linkText = link.firstElementChild as HTMLElement;
|
||||
|
||||
expect((view.getByText("Paseo") as HTMLElement).style.color).toBe("rgb(0, 122, 255)");
|
||||
expect(linkText.style.textDecorationLine).toBe("");
|
||||
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(linkText.style.textDecorationLine).toBe("underline");
|
||||
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(linkText.style.textDecorationLine).toBe("");
|
||||
|
||||
fireEvent.click(link);
|
||||
expect(onPress).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("preserves named and numeric entities as literal rendered text", () => {
|
||||
expect(prepareMarkdownSource("AT&T 😀 # heading and `&`").render()).toBe(
|
||||
"AT\\&T 😀 \\# heading and `&`",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,790 +1,195 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import {
|
||||
Image,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import Markdown, {
|
||||
MarkdownIt,
|
||||
type ASTNode,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
EnrichedMarkdownText,
|
||||
type MarkdownStyle,
|
||||
type CodeBlockRendererProps,
|
||||
} from "react-native-enriched-markdown";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
useAssistantFileLinkActions,
|
||||
useAssistantFileLinkEnvironment,
|
||||
} from "@/assistant-file-links";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownFenceBlock } from "@/components/markdown/fence";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import { createMarkdownParser } from "@/utils/markdown-parser";
|
||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
splitHtmlishMarkdown,
|
||||
type MarkdownDisplayPart,
|
||||
type MarkdownInlineImagePart,
|
||||
} from "./html-ish";
|
||||
import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size";
|
||||
import {
|
||||
getAssistantImageMetadata,
|
||||
setAssistantImageMetadata,
|
||||
} from "@/utils/assistant-image-metadata";
|
||||
import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups";
|
||||
import { colorMarkdownLinkChildren } from "./link-children";
|
||||
import { MarkdownLinkText } from "./link-text";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useMarkdownSource, type MarkdownImageContext } from "./images";
|
||||
|
||||
export type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
interface MarkdownWithStableRendererProps {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles> | ReturnType<typeof createCompactMarkdownStyles>;
|
||||
rules?: RenderRules;
|
||||
markdownit?: ReturnType<typeof MarkdownIt>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
allowedImageHandlers?: readonly string[];
|
||||
topLevelMaxExceededItem?: ReactNode;
|
||||
}
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||
|
||||
function markdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRendererProps> {
|
||||
return { style: createMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
function compactMarkdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRendererProps> {
|
||||
return { style: createCompactMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
// Serves PR comment bodies and the markdown file preview; agent chat passes its
|
||||
// own parser. The preview has to show the bytes on disk, so no typographer.
|
||||
const defaultMarkdownParser = createMarkdownParser({ linkify: true });
|
||||
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
export interface MarkdownRendererProps {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
rules?: RenderRules;
|
||||
markdownit?: ReturnType<typeof MarkdownIt>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
allowedImageHandlers?: readonly string[];
|
||||
topLevelMaxExceededItem?: ReactNode;
|
||||
enableHtmlish?: boolean;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({
|
||||
text,
|
||||
compact = false,
|
||||
rules,
|
||||
markdownit = defaultMarkdownParser,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
enableHtmlish = true,
|
||||
}: MarkdownRendererProps) {
|
||||
const markdownRules = useMemo(() => rules ?? createSharedMarkdownRules(), [rules]);
|
||||
const parts = useMemo(
|
||||
() => (enableHtmlish ? splitHtmlishMarkdown(text) : [{ kind: "markdown" as const, text }]),
|
||||
[enableHtmlish, text],
|
||||
);
|
||||
const rendererProps = useMemo(
|
||||
() => ({
|
||||
compact,
|
||||
rules: markdownRules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
}),
|
||||
[
|
||||
allowedImageHandlers,
|
||||
compact,
|
||||
markdownRules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
topLevelMaxExceededItem,
|
||||
],
|
||||
);
|
||||
|
||||
return <MarkdownPartList parts={parts} rendererProps={rendererProps} />;
|
||||
}
|
||||
|
||||
type MarkdownPartRendererProps = Omit<MarkdownRendererProps, "text" | "enableHtmlish"> & {
|
||||
rules: RenderRules;
|
||||
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 MarkdownPartList({
|
||||
parts,
|
||||
rendererProps,
|
||||
}: {
|
||||
parts: MarkdownDisplayPart[];
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const keyedGroups = useMemo(() => keyMarkdownGroups(groupMarkdownParts(parts)), [parts]);
|
||||
return (
|
||||
<>
|
||||
{keyedGroups.map(({ key, group }) =>
|
||||
group.kind === "part" ? (
|
||||
<MarkdownPart key={key} part={group.part} rendererProps={rendererProps} />
|
||||
) : (
|
||||
<MarkdownImageTextGroup key={key} group={group} rendererProps={rendererProps} />
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function keyMarkdownGroups(
|
||||
groups: MarkdownPartGroup[],
|
||||
): { key: string; group: MarkdownPartGroup }[] {
|
||||
return groups.map((group, index) => ({ key: `${group.kind}:${index}`, group }));
|
||||
}
|
||||
|
||||
function MarkdownPart({
|
||||
part,
|
||||
rendererProps,
|
||||
}: {
|
||||
part: MarkdownDisplayPart;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
if (part.kind === "details") {
|
||||
return <MarkdownDetails part={part} rendererProps={rendererProps} />;
|
||||
}
|
||||
|
||||
if (part.kind === "inlineImage") {
|
||||
return <MarkdownInlineImage part={part} onLinkPress={rendererProps.onLinkPress} />;
|
||||
}
|
||||
|
||||
if (part.text.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MarkdownFragment text={part.text} {...rendererProps} />;
|
||||
}
|
||||
|
||||
function MarkdownFragment({
|
||||
text,
|
||||
compact,
|
||||
rules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
}: MarkdownRendererProps & { rules: RenderRules }) {
|
||||
const uniProps = compact ? compactMarkdownStyleMapping : markdownStyleMapping;
|
||||
return (
|
||||
<ThemedMarkdown
|
||||
uniProps={uniProps}
|
||||
rules={rules}
|
||||
markdownit={markdownit}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={allowedImageHandlers}
|
||||
topLevelMaxExceededItem={topLevelMaxExceededItem}
|
||||
>
|
||||
{text}
|
||||
</ThemedMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
function useNaturalImageDimensions(part: MarkdownInlineImagePart): {
|
||||
natural: InlineImageDimensions | null;
|
||||
failed: boolean;
|
||||
setFailed: (failed: boolean) => void;
|
||||
} {
|
||||
const cached = useMemo(() => getAssistantImageMetadata({ source: part.src }), [part.src]);
|
||||
const [resolved, setResolved] = useState<{
|
||||
source: string;
|
||||
dimensions: InlineImageDimensions;
|
||||
} | null>(null);
|
||||
const natural = resolved?.source === part.src ? resolved.dimensions : cached;
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if ((part.width && part.height) || cached) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
Image.getSize(
|
||||
part.src,
|
||||
(width, height) => {
|
||||
setAssistantImageMetadata({ source: part.src }, { width, height });
|
||||
if (!cancelled) {
|
||||
setResolved({ source: part.src, dimensions: { width, height } });
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
setFailed(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cached, part.height, part.src, part.width]);
|
||||
|
||||
return { natural, failed, setFailed };
|
||||
}
|
||||
|
||||
function MarkdownInlineImage({
|
||||
part,
|
||||
onLinkPress,
|
||||
}: {
|
||||
part: Extract<MarkdownDisplayPart, { kind: "inlineImage" }>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
}) {
|
||||
const { natural: naturalDimensions } = useNaturalImageDimensions(part);
|
||||
const explicitDimensions = useMemo(
|
||||
() => ({ width: part.width, height: part.height }),
|
||||
[part.height, part.width],
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!part.href) return;
|
||||
if (onLinkPress?.(part.href) === false) return;
|
||||
void openExternalUrl(part.href);
|
||||
}, [onLinkPress, part.href]);
|
||||
const source = useMemo(() => ({ uri: part.src }), [part.src]);
|
||||
const imageSize = useMemo(
|
||||
() => resolveInlineImageSize({ explicit: explicitDimensions, natural: naturalDimensions }),
|
||||
[explicitDimensions, naturalDimensions],
|
||||
);
|
||||
const imageStyle = useMemo(() => [detailsStyles.inlineImage, imageSize], [imageSize]);
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
source={source}
|
||||
style={imageStyle}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={part.alt || undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!part.href) {
|
||||
return <View style={detailsStyles.inlineImageWrap}>{image}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable style={detailsStyles.inlineImageWrap} onPress={handlePress} accessibilityRole="link">
|
||||
{image}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const FLOW_IMAGE_MAX_HEIGHT = 18;
|
||||
|
||||
function MarkdownFlowImage({
|
||||
part,
|
||||
onLinkPress,
|
||||
}: {
|
||||
part: MarkdownInlineImagePart;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
}) {
|
||||
const { natural, failed, setFailed } = useNaturalImageDimensions(part);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!part.href) return;
|
||||
if (onLinkPress?.(part.href) === false) return;
|
||||
void openExternalUrl(part.href);
|
||||
}, [onLinkPress, part.href]);
|
||||
const handleError = useCallback(() => setFailed(true), [setFailed]);
|
||||
const source = useMemo(() => ({ uri: part.src }), [part.src]);
|
||||
const imageSize = useMemo(() => {
|
||||
const size = resolveInlineImageSize({
|
||||
explicit: { width: part.width, height: part.height },
|
||||
natural,
|
||||
});
|
||||
const scale = Math.min(1, FLOW_IMAGE_MAX_HEIGHT / size.height);
|
||||
return { width: Math.round(size.width * scale), height: Math.round(size.height * scale) };
|
||||
}, [natural, part.height, part.width]);
|
||||
const imageStyle = useMemo(() => [detailsStyles.flowImage, imageSize], [imageSize]);
|
||||
|
||||
if (failed) {
|
||||
if (!part.alt) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={detailsStyles.flowImageFallback}>
|
||||
<Text style={detailsStyles.flowImageFallbackText}>{part.alt}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
source={source}
|
||||
style={imageStyle}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={part.alt || undefined}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!part.href) {
|
||||
return image;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} accessibilityRole="link">
|
||||
{image}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownImageTextGroup({
|
||||
group,
|
||||
rendererProps,
|
||||
}: {
|
||||
group: Extract<MarkdownPartGroup, { kind: "imageText" }>;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<View style={detailsStyles.imageTextRow}>
|
||||
{group.images.map((image) => (
|
||||
<MarkdownFlowImage key={image.src} part={image} onLinkPress={rendererProps.onLinkPress} />
|
||||
))}
|
||||
<View style={detailsStyles.imageTextRowContent}>
|
||||
<MarkdownFragment text={group.lead} {...rendererProps} />
|
||||
</View>
|
||||
</View>
|
||||
{group.rest ? <MarkdownFragment text={group.rest} {...rendererProps} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownDetails({
|
||||
part,
|
||||
rendererProps,
|
||||
}: {
|
||||
part: Extract<MarkdownDisplayPart, { kind: "details" }>;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggle = useCallback(() => setOpen((current) => !current), []);
|
||||
const bodyParts = useMemo(
|
||||
() => part.bodyParts ?? [{ kind: "markdown" as const, text: part.body }],
|
||||
[part.body, part.bodyParts],
|
||||
);
|
||||
return (
|
||||
<View style={detailsStyles.container}>
|
||||
<Pressable style={detailsStyles.summaryRow} onPress={toggle} accessibilityRole="button">
|
||||
{open ? (
|
||||
<ChevronDown size={14} color={detailsStyles.summaryIcon.color} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={detailsStyles.summaryIcon.color} />
|
||||
)}
|
||||
<Text style={detailsStyles.summaryText}>{part.summary}</Text>
|
||||
</Pressable>
|
||||
{open ? (
|
||||
<View style={detailsStyles.body}>
|
||||
<MarkdownPartList parts={bodyParts} rendererProps={rendererProps} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownInheritedTextProps {
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
style?: TextStyle;
|
||||
monoSurface?: boolean;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function MarkdownInheritedText({
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
style: overrideStyle,
|
||||
monoSurface,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
children,
|
||||
}: MarkdownInheritedTextProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
return (
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
contentStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
|
||||
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface MarkdownListViewProps {
|
||||
baseStyle: ViewStyle;
|
||||
spacing: { marginTop: number; marginBottom: number };
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
|
||||
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface SharedMarkdownLinkProps {
|
||||
href: string;
|
||||
inheritedStyles: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SharedMarkdownLink({
|
||||
href,
|
||||
inheritedStyles,
|
||||
linkStyle,
|
||||
onLinkPress,
|
||||
children,
|
||||
}: SharedMarkdownLinkProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
if (!href) return;
|
||||
if (onLinkPress?.(href) === false) return;
|
||||
void openExternalUrl(href);
|
||||
}, [href, onLinkPress]);
|
||||
const style = useMemo(() => [inheritedStyles, linkStyle], [inheritedStyles, linkStyle]);
|
||||
|
||||
if (!isNative) {
|
||||
return (
|
||||
<MarkdownLinkText style={style} onPress={handlePress}>
|
||||
{children}
|
||||
</MarkdownLinkText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MarkdownInheritedText
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={linkStyle}
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownLinkHref(node: ASTNode): string {
|
||||
const href = node.attributes?.href;
|
||||
return typeof href === "string" ? href : "";
|
||||
}
|
||||
|
||||
export function createSharedMarkdownRules(): RenderRules {
|
||||
function markdownStyle(theme: Theme, compact = false): { markdownStyle: MarkdownStyle } {
|
||||
const fontSize = compact ? theme.fontSize.base : theme.fontSize.content;
|
||||
const paragraph = {
|
||||
fontSize,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
lineHeight: Math.round(fontSize * 1.4),
|
||||
color: theme.colors.foreground,
|
||||
marginTop: 0,
|
||||
marginBottom: theme.spacing[3],
|
||||
};
|
||||
const heading = (scale: number) => ({
|
||||
...paragraph,
|
||||
fontSize: Math.round(fontSize * scale),
|
||||
lineHeight: Math.round(fontSize * scale * 1.3),
|
||||
fontWeight: "bold",
|
||||
marginTop: theme.spacing[3],
|
||||
});
|
||||
return {
|
||||
text: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
textgroup: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.textgroup}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownTextSpan key={node.key} style={styles.hardbreak}>
|
||||
{"\n"}
|
||||
</MarkdownTextSpan>
|
||||
),
|
||||
softbreak: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownTextSpan key={node.key} style={styles.softbreak}>
|
||||
{"\n"}
|
||||
</MarkdownTextSpan>
|
||||
),
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownFenceBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
info={node.sourceInfo}
|
||||
phase="complete"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
>
|
||||
{node.content ?? ""}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
list_item: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||
</View>
|
||||
);
|
||||
markdownStyle: {
|
||||
paragraph,
|
||||
h1: heading(1.6),
|
||||
h2: heading(1.4),
|
||||
h3: heading(1.2),
|
||||
h4: heading(1.1),
|
||||
h5: heading(1),
|
||||
h6: heading(1),
|
||||
list: {
|
||||
...paragraph,
|
||||
bulletColor: theme.colors.foreground,
|
||||
markerColor: theme.colors.foregroundMuted,
|
||||
gapWidth: 7,
|
||||
itemSpacing: 4,
|
||||
marginLeft: 20,
|
||||
},
|
||||
blockquote: {
|
||||
...paragraph,
|
||||
color: theme.colors.foregroundMuted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: theme.colors.border,
|
||||
borderWidth: 3,
|
||||
padding: 12,
|
||||
gapWidth: 8,
|
||||
},
|
||||
link: { color: theme.colors.accent, underline: false },
|
||||
linkVariants: {
|
||||
"^(?!https?://).+": {
|
||||
color: theme.colors.accent,
|
||||
pill: { borderRadius: 4, paddingHorizontal: 3 },
|
||||
},
|
||||
},
|
||||
code: {
|
||||
fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
codeBlock: {
|
||||
...paragraph,
|
||||
fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
},
|
||||
table: {
|
||||
...paragraph,
|
||||
borderColor: theme.colors.border,
|
||||
headerBackgroundColor: theme.colors.surface2,
|
||||
},
|
||||
image: { height: 320, resizeMode: "contain", borderRadius: 8 },
|
||||
thematicBreak: { color: theme.colors.border, height: 1, marginTop: 12, marginBottom: 12 },
|
||||
},
|
||||
th: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||||
<MarkdownTableCellText key={node.key}>
|
||||
<View style={styles._VIEW_SAFE_th}>{children}</View>
|
||||
</MarkdownTableCellText>
|
||||
),
|
||||
td: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||||
<MarkdownTableCellText key={node.key}>
|
||||
<View style={styles._VIEW_SAFE_td}>{children}</View>
|
||||
</MarkdownTableCellText>
|
||||
),
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
link: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
onLinkPress?: (url: string) => boolean,
|
||||
) => (
|
||||
<SharedMarkdownLink
|
||||
key={node.key}
|
||||
href={getMarkdownLinkHref(node)}
|
||||
inheritedStyles={EMPTY_TEXT_STYLE}
|
||||
linkStyle={styles.link}
|
||||
onLinkPress={onLinkPress}
|
||||
>
|
||||
{colorMarkdownLinkChildren(children, styles.link.color)}
|
||||
</SharedMarkdownLink>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const detailsStyles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginBottom: theme.spacing[2],
|
||||
overflow: "hidden",
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
summaryIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
summaryText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
const regularStyle = (theme: Theme) => markdownStyle(theme);
|
||||
const compactStyle = (theme: Theme) => markdownStyle(theme, true);
|
||||
const ThemedCodeBlock = withUnistyles(HighlightedCodeBlock);
|
||||
const codeStyle = (theme: Theme) => ({
|
||||
inheritedStyles: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 18,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
},
|
||||
body: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
},
|
||||
inlineImageWrap: {
|
||||
alignSelf: "flex-start",
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
inlineImage: {},
|
||||
imageTextRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
imageTextRowContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
flowImage: {
|
||||
marginTop: 2,
|
||||
},
|
||||
flowImageFallback: {
|
||||
marginTop: 2,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
flowImageFallbackText: {
|
||||
fontSize: 10,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
textStyle: { backgroundColor: theme.colors.surface2, padding: 12, borderRadius: 8 },
|
||||
});
|
||||
function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
|
||||
return <ThemedCodeBlock code={code} language={language} uniProps={codeStyle} />;
|
||||
}
|
||||
const WEB_CONTAINER = { userSelect: "text" as const };
|
||||
const WEB_RENDERERS = { codeBlock: WebCodeBlock };
|
||||
|
||||
export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
onLinkPress?: (url: string) => unknown;
|
||||
}
|
||||
|
||||
/** All Markdown surfaces share the same engine and source adaptation. */
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({
|
||||
text,
|
||||
compact = false,
|
||||
onLinkPress,
|
||||
...imageContext
|
||||
}: MarkdownRendererProps) {
|
||||
const environment = useAssistantFileLinkEnvironment();
|
||||
const actions = useAssistantFileLinkActions();
|
||||
const client =
|
||||
imageContext.client ??
|
||||
(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 [image, setImage] = useState<ImageLightboxSource | null>(null);
|
||||
const pressLink = useStableEvent(({ url }: { url: string }) => {
|
||||
const source = { href: url, text: url };
|
||||
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
|
||||
else actions.open(source, "preferred");
|
||||
});
|
||||
const pressImage = useStableEvent(({ url }: { url: string }) =>
|
||||
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 = (
|
||||
<>
|
||||
<ThemedMarkdown
|
||||
markdown={markdown}
|
||||
containerStyle={isWeb ? WEB_CONTAINER : undefined}
|
||||
uniProps={compact ? compactStyle : regularStyle}
|
||||
flavor="github"
|
||||
selectable
|
||||
md4cFlags={MD4C_FLAGS}
|
||||
linkRecognition={LINK_RECOGNITION}
|
||||
linkPillContent={pills}
|
||||
enableTaskListItemToggle={false}
|
||||
enableLinkPreview={false}
|
||||
enableBlockContextMenu={false}
|
||||
streamingAnimation={false}
|
||||
onLinkPress={pressLink}
|
||||
onImagePress={pressImage}
|
||||
renderers={isWeb ? WEB_RENDERERS : undefined}
|
||||
/>
|
||||
{image ? <AttachmentLightbox source={image} onClose={closeImage} /> : null}
|
||||
</>
|
||||
);
|
||||
return isWeb && Object.keys(imageSources).length > 0 ? (
|
||||
<View dataSet={markdownCopyImageSourcesDataSet(imageSources)}>{content}</View>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { decodeString } from "micromark-util-decode-string";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import type { Nodes } from "mdast";
|
||||
|
||||
export interface MarkdownImage {
|
||||
source: string;
|
||||
alt: string;
|
||||
title: string | null;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Preserve source spelling; adapt workspace images, spoiler delimiters, and entity text. */
|
||||
export function prepareMarkdownSource(source: string) {
|
||||
const images: MarkdownImage[] = [];
|
||||
const edits: { start: number; end: number; text: string }[] = [];
|
||||
if (!source.includes("![") && !source.includes("||") && !source.includes("&")) {
|
||||
return { images, render: () => source };
|
||||
}
|
||||
const tree = fromMarkdown(source);
|
||||
const definitions = new Map<string, { url: string; title?: string | null }>();
|
||||
function definitionsIn(node: Nodes) {
|
||||
if (node.type === "definition") definitions.set(node.identifier, node);
|
||||
if ("children" in node) node.children.forEach(definitionsIn);
|
||||
}
|
||||
definitionsIn(tree);
|
||||
function visit(node: Nodes) {
|
||||
const start = node.position?.start.offset;
|
||||
const end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) {
|
||||
if (node.type === "image" || node.type === "imageReference") {
|
||||
const target = node.type === "image" ? node : definitions.get(node.identifier);
|
||||
if (target)
|
||||
images.push({
|
||||
source: target.url,
|
||||
alt: node.alt ?? "",
|
||||
title: target.title ?? null,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (node.type === "text") {
|
||||
const text = source
|
||||
.slice(start, end)
|
||||
.replace(/(?<!\\)&(?:#(?:[xX][\da-fA-F]+|\d+)|[a-zA-Z][a-zA-Z\d]+);/g, (entity) =>
|
||||
decodeString(entity).replace(/[!-/:-@[-`{-~]/g, "\\$&"),
|
||||
)
|
||||
.replace(/(?<!\\)\|\|/g, "\\|\\|");
|
||||
if (text !== source.slice(start, end)) edits.push({ start, end, text });
|
||||
}
|
||||
}
|
||||
if ("children" in node) node.children.forEach(visit);
|
||||
}
|
||||
visit(tree);
|
||||
return {
|
||||
images,
|
||||
render(urls: ReadonlyMap<string, string> = new Map()) {
|
||||
const replacements = [...edits];
|
||||
for (const image of images) {
|
||||
if (/^(https?:|data:|blob:)/i.test(image.source)) continue;
|
||||
const url = urls.get(image.source);
|
||||
replacements.push({
|
||||
start: image.start,
|
||||
end: image.end,
|
||||
text: url
|
||||
? `![${image.alt.replace(/[[\]\\]/g, "\\$&")}](<${url}>${image.title ? ` ${JSON.stringify(image.title)}` : ""})`
|
||||
: image.alt,
|
||||
});
|
||||
}
|
||||
let result = source;
|
||||
for (const edit of replacements.sort((a, b) => b.start - a.start)) {
|
||||
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user