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