mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Scale inline code with the enclosing block using theme tokens, and preserve its size and font through bold and links. Use contextual package patches so clean installs reproduce the verified native source.
822 lines
32 KiB
TypeScript
822 lines
32 KiB
TypeScript
import { fromMarkdown } from "mdast-util-from-markdown";
|
||
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
|
||
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
|
||
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
|
||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
|
||
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
|
||
import { QueryClient } from "@tanstack/react-query";
|
||
import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding";
|
||
import { describe, expect, it } from "vitest";
|
||
import { darkTheme, lightTheme } from "@/styles/theme";
|
||
import { createMarkdownStyles } from "./styles";
|
||
import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source";
|
||
import { normalizeMarkdownHtml } from "./html";
|
||
|
||
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("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("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("gives an image alone on its line its own paragraph", () => {
|
||
expect(
|
||
prepareMarkdownSource("Before the shot:\n\nAfter it.").render(),
|
||
).toBe("Before the shot:\n\n\n\nAfter it.");
|
||
expect(prepareMarkdownSource("\n").render()).toBe(
|
||
"\n\n",
|
||
);
|
||
expect(
|
||
prepareMarkdownSource(
|
||
"Line one\\\n[](https://x)\nLine three",
|
||
).render(),
|
||
).toBe("Line one\n\n[](https://x)\n\nLine three");
|
||
});
|
||
it("keeps images that share a line with text inline", () => {
|
||
const source = "Icon  in text\n caption\nNext line";
|
||
expect(prepareMarkdownSource(source).render()).toBe(source);
|
||
});
|
||
it("splits own-line images inside quotes and list items without ending the container", () => {
|
||
expect(prepareMarkdownSource("> Quote text\n> \n> More").render()).toBe(
|
||
"> Quote text\n>\n> \n>\n> More",
|
||
);
|
||
expect(
|
||
prepareMarkdownSource("- Item text\n \n More\n- Next").render(),
|
||
).toBe("- Item text\n\n \n\n More\n- Next");
|
||
expect(prepareMarkdownSource("- \n Caption line").render()).toBe(
|
||
"- \n\n Caption line",
|
||
);
|
||
});
|
||
it("renders own-line images as blocks through the library parser", async () => {
|
||
const source =
|
||
"Before:\n\nIcon  in text\n> Quoted\n> ";
|
||
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
|
||
hardSoftBreaks: true,
|
||
latexMath: false,
|
||
});
|
||
markInlineImages(tree);
|
||
const images: ASTNode[] = [];
|
||
const collect = (node: ASTNode) => {
|
||
if (node.type === "Image") images.push(node);
|
||
node.children?.forEach(collect);
|
||
};
|
||
collect(tree);
|
||
expect(
|
||
images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]),
|
||
).toEqual([
|
||
["https://x/a.png", false],
|
||
["https://x/i.png", true],
|
||
["https://x/q.png", false],
|
||
]);
|
||
});
|
||
it("escapes spoiler delimiters only in prose", () => {
|
||
expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe(
|
||
"Show \\|\\|words\\|\\| and `||code||`.",
|
||
);
|
||
});
|
||
});
|
||
|
||
it("preserves named and numeric entities as literal rendered text", () => {
|
||
expect(prepareMarkdownSource("AT&T 😀 # heading and `&`").render()).toBe(
|
||
"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",
|
||
"my%20file.ts",
|
||
"src/file.ts:10-20",
|
||
"src/file.ts(10,2)",
|
||
])("recognizes inline paths through the file resolver: %s", (path) => {
|
||
const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/repo");
|
||
expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true);
|
||
expect(prepared.linkSource(path, "inline-code")).toEqual({
|
||
href: path,
|
||
text: path,
|
||
sourceType: "inline-code",
|
||
});
|
||
});
|
||
|
||
it("preserves stable inline-code file classification through the library parser", async () => {
|
||
const paths = [
|
||
"src/app/[id].tsx",
|
||
"src/app/(tabs)/index.tsx",
|
||
"src/a$b.ts",
|
||
".gitignore",
|
||
"src/index.ts#L10-L20",
|
||
"src/index.ts:10-20",
|
||
"src/index.ts:20-10",
|
||
"src/index.ts#L0",
|
||
"src/index.ts?raw",
|
||
"before/after",
|
||
"word.word",
|
||
"README.md",
|
||
"./scripts/run",
|
||
"src/file.ts(10,2)",
|
||
"src/file.ts lines 10-20",
|
||
"/tmp/[route].tsx",
|
||
"file:///tmp/file.ts#L10-L20",
|
||
];
|
||
const source = paths.map((path) => `\`${path}\``).join(" and ");
|
||
const prepared = prepareMarkdownSource(source, "/repo");
|
||
const tree = recognizeTextLinks(
|
||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||
prepared.linkRecognition.text,
|
||
prepared.linkRecognition.inlineCode,
|
||
);
|
||
const links: string[] = [];
|
||
function collect(node: ASTNode) {
|
||
if (node.type === "Link") links.push(String(node.attributes?.url));
|
||
node.children?.forEach(collect);
|
||
}
|
||
collect(tree);
|
||
expect(links).toEqual([
|
||
"src/app/[id].tsx",
|
||
"src/app/(tabs)/index.tsx",
|
||
"src/a$b.ts",
|
||
".gitignore",
|
||
"src/index.ts#L10-L20",
|
||
"src/index.ts:10-20",
|
||
"README.md",
|
||
"./scripts/run",
|
||
"src/file.ts(10,2)",
|
||
"/tmp/[route].tsx",
|
||
"file:///tmp/file.ts#L10-L20",
|
||
]);
|
||
expect(
|
||
classifyAssistantFileLink("src/index.ts#L10-L20", { workspaceRoot: "/repo" }),
|
||
).toMatchObject({ target: { path: "/repo/src/index.ts", lineStart: 10, lineEnd: 20 } });
|
||
});
|
||
|
||
it("lets the engine own code boundaries while classifying candidate values", async () => {
|
||
const source = [
|
||
"```ts\n`src/fenced.ts`\n```",
|
||
"`` src/app/[id].tsx `` and ` src/spaced.ts `",
|
||
"``src/a`b.ts`` and `src/after.ts`",
|
||
"\\`src/escaped.ts` and `src/final.ts`",
|
||
"`\nsrc/newline.ts\n`",
|
||
"[`src/explicit.ts`](https://example.com)",
|
||
].join("\n\n");
|
||
const prepared = prepareMarkdownSource(source, "/repo");
|
||
const tree = recognizeTextLinks(
|
||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||
prepared.linkRecognition.text,
|
||
prepared.linkRecognition.inlineCode,
|
||
);
|
||
const recognized: string[] = [];
|
||
function collect(node: ASTNode) {
|
||
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
|
||
recognized.push(String(node.attributes.url));
|
||
}
|
||
node.children?.forEach(collect);
|
||
}
|
||
collect(tree);
|
||
expect(recognized).toEqual([
|
||
"src/app/[id].tsx",
|
||
" src/spaced.ts ",
|
||
"src/a`b.ts",
|
||
"src/after.ts",
|
||
"src/escaped.ts",
|
||
"src/final.ts",
|
||
"src/newline.ts",
|
||
]);
|
||
});
|
||
|
||
describe("bare URL sentence punctuation", () => {
|
||
it.each([".", ",", ";", ":", "!", "?", ")", ").", "...", "?!"])(
|
||
"leaves %s outside the link and preserves the rendered sentence",
|
||
async (punctuation) => {
|
||
const url = "https://github.com/getpaseo/paseo/pull/7357";
|
||
const source = `${url}${punctuation} Not merged.`;
|
||
const prepared = prepareMarkdownSource(source);
|
||
const tree = recognizeTextLinks(
|
||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||
prepared.linkRecognition.text,
|
||
prepared.linkRecognition.inlineCode,
|
||
);
|
||
expect(tree.children?.[0]?.children).toEqual([
|
||
{
|
||
type: "Link",
|
||
attributes: { url },
|
||
children: [{ type: "Text", content: url }],
|
||
},
|
||
{ type: "Text", content: `${punctuation} Not merged.` },
|
||
]);
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
"https://example.com/a.b",
|
||
"https://example.com/a?foo=bar&baz=2",
|
||
"https://example.com/a#L10",
|
||
"https://en.wikipedia.org/wiki/Function_(mathematics)",
|
||
"https://example.com/a_(b_(c))",
|
||
])("preserves internal URL punctuation: %s", async (url) => {
|
||
const tree = await parseMarkdown(prepareMarkdownSource(`${url}.`).render());
|
||
expect(tree.children?.[0]?.children).toEqual([
|
||
{
|
||
type: "Link",
|
||
attributes: { url },
|
||
children: [{ type: "Text", content: url }],
|
||
},
|
||
{ type: "Text", content: "." },
|
||
]);
|
||
});
|
||
|
||
it.each([
|
||
["http://127.0.0.1:8059/?token=t-1", ""],
|
||
["http://example.com:8080/a", "."],
|
||
["http://localhost:8059/?token=t-1", "."],
|
||
["http://localhost:8059/?token=t-1", ")"],
|
||
["http://user@127.0.0.1:8059/?token=t-1", '"'],
|
||
["http://user:pass@127.0.0.1:8059/?token=t-1", ""],
|
||
["http://localhost", ":"],
|
||
["http://[::1]:8059/?token=t-1", "'"],
|
||
["https://github.com/o/r/compare/main...user:repo:fix/branch", ""],
|
||
["https://example.com/a:b/c", ":"],
|
||
])("links %s whole and leaves %j outside", async (url, punctuation) => {
|
||
const prepared = prepareMarkdownSource(`See ${url}${punctuation} now`);
|
||
const tree = recognizeTextLinks(
|
||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||
prepared.linkRecognition.text,
|
||
prepared.linkRecognition.inlineCode,
|
||
);
|
||
expect(tree.children?.[0]?.children).toEqual([
|
||
{ type: "Text", content: "See " },
|
||
{
|
||
type: "Link",
|
||
attributes: expect.objectContaining({ url }),
|
||
children: [{ type: "Text", content: url }],
|
||
},
|
||
{ type: "Text", content: `${punctuation} now` },
|
||
]);
|
||
});
|
||
|
||
it.each(["[Authored](https://example.com/a.)", "<https://example.com/a.>"])(
|
||
"preserves punctuation in explicit destinations: %s",
|
||
async (source) => {
|
||
const tree = await parseMarkdown(prepareMarkdownSource(source).render());
|
||
expect(tree.children?.[0]?.children?.[0]?.attributes?.url).toBe("https://example.com/a.");
|
||
},
|
||
);
|
||
});
|
||
|
||
it("requires workspace context for relative inline paths and escapes literal regex characters", () => {
|
||
const source = "`src/app/[id].tsx` and `src/a+b.ts` and `README.md` and `/tmp/file.ts`";
|
||
const withoutWorkspace = prepareMarkdownSource(source).linkRecognition.inlineCode;
|
||
expect(withoutWorkspace.test("src/app/[id].tsx")).toBe(false);
|
||
expect(withoutWorkspace.test("README.md")).toBe(false);
|
||
expect(withoutWorkspace.test("/tmp/file.ts")).toBe(true);
|
||
const prepared = prepareMarkdownSource(source, "/repo");
|
||
const native = toNativeLinkRecognition(prepared.linkRecognition);
|
||
const pattern = new RegExp(native.inlineCode.pattern);
|
||
expect(pattern.test("src/app/[id].tsx")).toBe(true);
|
||
expect(pattern.test("src/a+b.ts")).toBe(true);
|
||
expect(pattern.test("src/app/i.tsx")).toBe(false);
|
||
expect(pattern.test("src/ab.ts")).toBe(false);
|
||
});
|
||
|
||
it("recognizes bare Unicode filenames and preserves inline-code URLs", () => {
|
||
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.");
|
||
expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([
|
||
"说明.md",
|
||
]);
|
||
expect(prepared.linkRecognition.inlineCode?.test("https://example.com")).toBe(true);
|
||
});
|
||
|
||
it("keeps explicit and inline-code links distinct when their URL strings match", () => {
|
||
const url = "/tmp/my%20file.ts";
|
||
const prepared = prepareMarkdownSource(`[open](${url}) and \`${url}\``);
|
||
expect(prepared.linkSource(url, "markdown")).toEqual({ href: url, text: url });
|
||
expect(prepared.linkSource(url, "inline-code")).toEqual({
|
||
href: url,
|
||
text: url,
|
||
sourceType: "inline-code",
|
||
});
|
||
});
|
||
|
||
it("extracts only Mermaid fences for the native diagram host", async () => {
|
||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||
const source =
|
||
"First.\n\n```ts\nconst n = 1;\n```\n\n```mermaid\ngraph TD\nA-->B\n```\n\n> Last.";
|
||
expect(splitMermaidFences(source)).toEqual([
|
||
{ kind: "markdown", id: "0", source: "First.\n\n```ts\nconst n = 1;\n```\n\n" },
|
||
{ kind: "mermaid", id: "1", source: "graph TD\nA-->B" },
|
||
{ kind: "markdown", id: "2", source: "\n\n> Last." },
|
||
]);
|
||
expect(splitMermaidFences("```md\n```mermaid\n```")).toEqual([
|
||
{ kind: "markdown", id: "0", source: "```md\n```mermaid\n```" },
|
||
]);
|
||
});
|
||
|
||
it("keeps reference links available across native Mermaid boundaries", async () => {
|
||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||
const parts = splitMermaidFences(
|
||
"[Before][file]\n\n```mermaid\ngraph TD\nA-->B\n```\n\n[After][file]\n\n[file]: src/file.ts",
|
||
);
|
||
for (const part of parts.filter((candidate) => candidate.kind === "markdown"))
|
||
expect(part.source).toContain("[file]: <src/file.ts>");
|
||
});
|
||
|
||
it("recognizes mixed-case Mermaid fence info and incomplete streaming fences", async () => {
|
||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||
expect(splitMermaidFences("```Mermaid title\ngraph TD\nA-->B")).toEqual([
|
||
{ kind: "mermaid", id: "0", source: "graph TD\nA-->B" },
|
||
]);
|
||
});
|
||
|
||
it("carries reference definitions nested in quotes across a diagram", async () => {
|
||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||
const parts = splitMermaidFences(
|
||
"[open][file]\n\n```mermaid\ngraph TD\nA-->B\n```\n\n> [file]: src/file.ts",
|
||
);
|
||
expect(parts[0].source).toContain("[file]: <src/file.ts>");
|
||
});
|
||
|
||
it("keeps diagram identity when an earlier workspace image resolves", async () => {
|
||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||
const source = "\n\n```mermaid\ngraph TD\nA-->B\n```";
|
||
const before = splitMermaidFences(source).find((part) => part.kind === "mermaid");
|
||
const after = splitMermaidFences(
|
||
source.replace("a.png", "file:///long/cache/path/image.png"),
|
||
).find((part) => part.kind === "mermaid");
|
||
expect(before).toHaveProperty("id");
|
||
expect(after?.id).toBe(before?.id);
|
||
});
|
||
|
||
it("keeps workspace-relative hover titles on explicit absolute file links", () => {
|
||
expect(prepareMarkdownSource("[Open](/repo/src/file.ts:42-45)", "/repo").render()).toBe(
|
||
'[Open](/repo/src/file.ts:42-45 "src/file.ts:42-45")',
|
||
);
|
||
});
|
||
|
||
it.each(['""', "''", "()"])("preserves an authored empty link title: %s", (title) => {
|
||
const source = `[Open](/repo/file.ts ${title})`;
|
||
expect(prepareMarkdownSource(source, "/repo").render()).toBe(source);
|
||
});
|
||
|
||
describe("shared Markdown theme colours", () => {
|
||
it.each([
|
||
["dark", darkTheme],
|
||
["light", lightTheme],
|
||
] as const)("uses %s theme colours for tables and task labels", (_name, theme) => {
|
||
const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 });
|
||
expect(style.table).toMatchObject({
|
||
color: theme.colors.foreground,
|
||
headerTextColor: theme.colors.foreground,
|
||
headerBackgroundColor: theme.colors.surface2,
|
||
rowEvenBackgroundColor: theme.colors.surface0,
|
||
rowOddBackgroundColor: theme.colors.surface1,
|
||
borderColor: theme.colors.border,
|
||
});
|
||
expect(style.list).toMatchObject({
|
||
color: theme.colors.foreground,
|
||
bulletColor: theme.colors.foregroundMuted,
|
||
markerColor: theme.colors.foregroundMuted,
|
||
});
|
||
expect(style.h6?.color).toBe(theme.colors.foregroundMuted);
|
||
expect(style.taskList).toMatchObject({
|
||
checkedTextColor: theme.colors.foregroundMuted,
|
||
checkedColor: theme.colors.accent,
|
||
checkmarkColor: theme.colors.accentForeground,
|
||
borderColor: theme.colors.foregroundMuted,
|
||
});
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
["dark", darkTheme],
|
||
["light", lightTheme],
|
||
] as const)(
|
||
"uses %s theme colours for quotes, admonitions, code, links and dividers",
|
||
(_name, theme) => {
|
||
const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 });
|
||
expect(style.blockquote).toMatchObject({
|
||
color: `${theme.colors.foreground}cc`,
|
||
backgroundColor: theme.colors.surface1,
|
||
borderColor: theme.colors.surface2,
|
||
admonitions: {
|
||
note: { color: theme.colors.accentBright, backgroundColor: theme.colors.surface2 },
|
||
tip: { color: theme.colors.statusSuccess, backgroundColor: theme.colors.surface2 },
|
||
important: { color: theme.colors.statusMerged, backgroundColor: theme.colors.surface2 },
|
||
warning: { color: theme.colors.statusWarning, backgroundColor: theme.colors.surface2 },
|
||
caution: { color: theme.colors.statusDanger, backgroundColor: theme.colors.surface2 },
|
||
},
|
||
});
|
||
expect(style.code).toMatchObject({
|
||
color: theme.colors.foreground,
|
||
backgroundColor: theme.colors.surface2,
|
||
borderColor: "transparent",
|
||
});
|
||
expect(style.link?.color).toBe(theme.colors.accentBright);
|
||
expect(style.linkVariants?.["^(?!https?://).+"]).toEqual({
|
||
color: theme.colors.accentBright,
|
||
});
|
||
expect(style.thematicBreak?.color).toBe(theme.colors.border);
|
||
},
|
||
);
|
||
|
||
it.each([darkTheme, lightTheme])("supplies the theme selection colour to the renderer", (theme) => {
|
||
expect(createMarkdownStyles(theme, { blockGap: 16 })).toHaveProperty(
|
||
"selectionColor",
|
||
theme.colors.terminal.selectionBackground,
|
||
);
|
||
});
|
||
|
||
describe("GitHub comment HTML normalization", () => {
|
||
it.each([
|
||
[
|
||
'<img alt="P1" src="https://example.com/p1.svg" align="top">',
|
||
"",
|
||
],
|
||
['<a href="https://example.com/review">▶</a>', "[▶](<https://example.com/review>)"],
|
||
["Before<br>After<br/>End", "Before \nAfter \nEnd"],
|
||
["<b>bold</b> and <strong>strong</strong>", "**bold** and **strong**"],
|
||
["<i>italic</i> and <em>emphasis</em>", "*italic* and *emphasis*"],
|
||
["Use <code>const n = 1</code>.", "Use `const n = 1`."],
|
||
["H<sub>2</sub>O and x<sup>2</sup>", "H2O and x2"],
|
||
[
|
||
"<details><summary>Review details</summary>Body **stays**.</details>",
|
||
"**Review details**\n\nBody **stays**.",
|
||
],
|
||
["Before<!-- private -->After", "BeforeAfter"],
|
||
[
|
||
"<details><summary><h3>Important Files Changed</h3></summary>Body</details>",
|
||
"**Important Files Changed**\n\nBody",
|
||
],
|
||
[
|
||
"<details><summary><p>First</p><p>Second</p></summary>Body</details>",
|
||
"**First Second**\n\nBody",
|
||
],
|
||
["<span>file.</span>ts", "file.ts"],
|
||
['<a href="https://example.com">Git</a>Hub', "[Git](<https://example.com>)Hub"],
|
||
["<span>Git</span><span>Hub</span>", "GitHub"],
|
||
["<span>Git</span> Hub", "Git Hub"],
|
||
["<div><span>Keep the content</span></div>", "Keep the content"],
|
||
[
|
||
'<a href="https://example.com"><strong>Review</strong></a>',
|
||
"[**Review**](<https://example.com>)",
|
||
],
|
||
['`<img src="literal.png">` and <b>bold</b>', '`<img src="literal.png">` and **bold**'],
|
||
[
|
||
'```html\n<img src="literal.png">\n```\n\n<b>bold</b>',
|
||
'```html\n<img src="literal.png">\n```\n\n**bold**',
|
||
],
|
||
['~~~html\n<a href="literal">link</a>\n~~~', '~~~html\n<a href="literal">link</a>\n~~~'],
|
||
[
|
||
'<details><summary>Code</summary>\n\n```html\n<img src="literal.png">\n```\n</details>',
|
||
'**Code**\n\n\n\n```html\n<img src="literal.png">\n```\n',
|
||
],
|
||
[
|
||
"<https://example.com> and <user@example.com>",
|
||
"<https://example.com> and <user@example.com>",
|
||
],
|
||
])("normalizes %s without changing code", (source, expected) => {
|
||
expect(prepareMarkdownSource(source).render()).toBe(expected);
|
||
});
|
||
});
|
||
|
||
it("preserves backslashes and decodes entities inside HTML code", () => {
|
||
const source = String.raw`<code>\*literal\* <img></code>`;
|
||
expect(prepareMarkdownSource(source).render()).toBe("`" + String.raw`\*literal\* <img>` + "`");
|
||
});
|
||
|
||
it.each([false, true])(
|
||
"caps inline images to the paragraph line height (compact: %s)",
|
||
(compact) => {
|
||
const { markdownStyle } = createMarkdownStyles(darkTheme, { compact, blockGap: 16 });
|
||
expect(markdownStyle.inlineImage?.size).toBe(markdownStyle.paragraph?.lineHeight);
|
||
},
|
||
);
|
||
|
||
it("shows native SVG badges as alt text without reserving an image box", () => {
|
||
const source = " **Finding**";
|
||
const prepared = prepareMarkdownSource(source, undefined, { supportsSvg: false });
|
||
expect(prepared.render()).toBe("P1 **Finding**");
|
||
expect(prepared.images).toEqual([]);
|
||
});
|
||
|
||
it("collapses images that fail to load or decode to their alt text", () => {
|
||
const prepared = prepareMarkdownSource(
|
||
" and ",
|
||
);
|
||
expect(
|
||
prepared.render(
|
||
new Map([["broken.png", "file:///broken.png"]]),
|
||
new Set(["https://example.com/broken.png", "broken.png"]),
|
||
),
|
||
).toBe("Remote and Local");
|
||
});
|
||
|
||
it("preserves angle-bracket image destinations and authored link titles during HTML normalization", () => {
|
||
const prepared = prepareMarkdownSource(
|
||
' and [open](<my file.png> "<b>literal title</b>")',
|
||
);
|
||
expect(prepared.images.map((image) => image.source)).toEqual(["my file.png"]);
|
||
expect(prepared.render(new Map([["my file.png", "blob:shot"]]))).toBe(
|
||
' and [open](<my file.png> "<b>literal title</b>")',
|
||
);
|
||
});
|
||
|
||
it("uses the first repeated image reference definition", () => {
|
||
const source = "![chart][image]\n\n[image]: first.png\n[image]: second.png";
|
||
const prepared = prepareMarkdownSource(source);
|
||
expect(prepared.images.map((image) => image.source)).toEqual(["first.png"]);
|
||
expect(prepared.render(new Map([["first.png", "blob:first"]]))).toBe(
|
||
"\n\n[image]: first.png\n[image]: second.png",
|
||
);
|
||
});
|
||
|
||
it("preserves authored adjacency in the Greptile header", () => {
|
||
const source =
|
||
'<h2><a href="https://app.greptile.com/api/retrigger?id=81261069"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=2"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"><img alt="Retrigger" src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2" align="right"></picture></a><a href="https://app.greptile.com/paseo/github/getpaseo/paseo/-/pull/6973"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewInGreptileDark.svg?v=2"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewInGreptile.svg?v=2"><img alt="View in Greptile" src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewInGreptile.svg?v=2" align="right"></picture></a>Confidence Score: 2/5</h2>';
|
||
const rendered = prepareMarkdownSource(source, undefined, { supportsSvg: false }).render();
|
||
expect(rendered).toContain(
|
||
"[Retrigger](<https://app.greptile.com/api/retrigger?id=81261069>)[View in Greptile]",
|
||
);
|
||
expect(rendered).toContain(")Confidence Score: 2/5");
|
||
});
|
||
|
||
it("keeps paragraph, heading and table-row boundaries when stripping HTML", () => {
|
||
const source =
|
||
"<div>First</div><p>Second<br>line</p><h3>Heading</h3><table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>";
|
||
const rendered = prepareMarkdownSource(source).render();
|
||
expect(rendered).toBe("First\n\nSecond \nline\n\nHeading\n\nA B\n\nC D");
|
||
});
|
||
|
||
it("normalizes images following HTML code in the same HTML block", () => {
|
||
const prepared = prepareMarkdownSource('<code>\nx\n</code>\n<img src="shot.png">');
|
||
expect(prepared.images.map((image) => image.source)).toEqual(["shot.png"]);
|
||
expect(prepared.render(new Map([["shot.png", "blob:shot"]]))).toContain("");
|
||
expect(prepared.render()).toContain("`\nx\n`");
|
||
});
|
||
|
||
it("preserves literal Markdown punctuation in unsupported and failed image labels", () => {
|
||
const label = String.raw`\*literal\* \[label\]`;
|
||
expect(
|
||
prepareMarkdownSource(``, undefined, { supportsSvg: false }).render(),
|
||
).toBe(label);
|
||
expect(
|
||
prepareMarkdownSource(``).render(
|
||
new Map(),
|
||
new Set(["https://example.com/badge.png"]),
|
||
),
|
||
).toBe(label);
|
||
});
|
||
|
||
it("retries transient image decoding failures instead of caching an unavailable image", async () => {
|
||
const client = new QueryClient();
|
||
let attempts = 0;
|
||
const decoded = await client.fetchQuery({
|
||
queryKey: ["markdownImageDecode", "transient.png"],
|
||
retry: 1,
|
||
retryDelay: 0,
|
||
queryFn: () =>
|
||
decodeMarkdownImage("transient.png", (_uri, success, failure) => {
|
||
attempts += 1;
|
||
if (attempts === 1) failure(new Error("temporarily unavailable"));
|
||
else success(20, 20);
|
||
}),
|
||
});
|
||
expect(decoded).toBe(true);
|
||
expect(attempts).toBe(2);
|
||
client.clear();
|
||
});
|
||
|
||
it("keeps the document's first reference definition across every native Mermaid segment", async () => {
|
||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||
const before = '[first]: /repo/first.ts "Original"\n\n';
|
||
const after = '\n\n[open][first]\n\n[first]: /repo/second.ts "Later"';
|
||
const source = `${before}\`\`\`mermaid\ngraph TD\nA-->B\n\`\`\`${after}`;
|
||
const firstDefinition = (markdown: string) =>
|
||
fromMarkdown(markdown).children.find((node) => node.type === "definition");
|
||
const canonical = firstDefinition(source);
|
||
const segments = splitMermaidFences(source);
|
||
for (const segment of segments.filter((candidate) => candidate.kind === "markdown")) {
|
||
expect(firstDefinition(segment.source)).toMatchObject({
|
||
identifier: canonical?.identifier,
|
||
url: canonical?.url,
|
||
title: canonical?.title,
|
||
});
|
||
}
|
||
expect(segments[0].source.endsWith(before)).toBe(true);
|
||
expect(segments[2].source.endsWith(after)).toBe(true);
|
||
expect(segments[1]).toEqual({ kind: "mermaid", id: "1", source: "graph TD\nA-->B" });
|
||
});
|
||
|
||
it("reserves pending image geometry until decode succeeds and collapses only confirmed failures", () => {
|
||
const source = "\n\n";
|
||
const prepared = prepareMarkdownSource(source);
|
||
const pending = prepared.render(
|
||
new Map(),
|
||
new Set(),
|
||
new Set(prepared.images.map((image) => image.source)),
|
||
);
|
||
expect(pending.match(/!\[/g)).toHaveLength(2);
|
||
expect(pending).toContain("data:image/png;base64,");
|
||
expect(prepared.render(new Map(), new Set(prepared.images.map((image) => image.source)))).toBe(
|
||
"Local\n\nRemote",
|
||
);
|
||
});
|
||
|
||
it("normalizes a large HTML table into one block per row", () => {
|
||
const cell = "cell".repeat(8);
|
||
const rows = 8000;
|
||
const source = `<table>${`<tr><td>${cell}</td><td>${cell}</td></tr>`.repeat(rows)}</table>`;
|
||
const rendered = normalizeMarkdownHtml(source);
|
||
expect(rendered.split("\n\n")).toHaveLength(rows);
|
||
expect(rendered.startsWith(`${cell} ${cell}`)).toBe(true);
|
||
expect(rendered.endsWith(`${cell} ${cell}`)).toBe(true);
|
||
});
|
||
|
||
it.each([
|
||
"3/4",
|
||
"1.5",
|
||
"v1.2",
|
||
"10/10",
|
||
"2024/10/16",
|
||
"10.0.0.1",
|
||
"word.word",
|
||
"example.com",
|
||
"file.tsxextra",
|
||
"v1.2.3-beta",
|
||
"123/456.789",
|
||
"×/÷",
|
||
"2024/10/16",
|
||
])("leaves non-path tokens unlinked in prose and inline code: %s", (token) => {
|
||
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then \`${token}\`.`, "/repo");
|
||
expect([...`Value ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
|
||
expect(linkRecognition.inlineCode.test(token)).toBe(false);
|
||
});
|
||
|
||
it.each([
|
||
"src/index.ts",
|
||
"README.md",
|
||
"./scripts/run",
|
||
"./x/y",
|
||
"../x/y",
|
||
"/x/y",
|
||
"a/b/c",
|
||
String.raw`C:\Users\x`,
|
||
"src/index.ts:12:3",
|
||
"src/run:12",
|
||
"src/run:12:3",
|
||
"src/run:12-20",
|
||
"~/dev/paseo",
|
||
String.raw`C:\Users\x\file.txt`,
|
||
"file.ts:12",
|
||
"file.ts:12-20",
|
||
"123.ts",
|
||
"FILE.TS",
|
||
"https://example.com/path",
|
||
])("recognizes complete paths and URLs in prose: %s", (token) => {
|
||
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then \`${token}\`.`, "/repo");
|
||
expect(
|
||
[...`Open ${token} then continue.`.matchAll(linkRecognition.text)].map(([text]) => text),
|
||
).toEqual([token]);
|
||
});
|
||
|
||
it.each([
|
||
"before/after",
|
||
"and/or",
|
||
"input/output",
|
||
"read/write",
|
||
"packages/app",
|
||
"123/file",
|
||
"src/123",
|
||
])("leaves extensionless relative paths unlinked in prose and inline code: %s", (token) => {
|
||
const { linkRecognition } = prepareMarkdownSource(
|
||
`Compare ${token}, then \`${token}\`.`,
|
||
"/repo",
|
||
);
|
||
expect([...`Compare ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
|
||
expect(linkRecognition.inlineCode.test(token)).toBe(false);
|
||
});
|
||
|
||
it("keeps prose and inline-code recognition enabled through the native regex adapter", () => {
|
||
const { linkRecognition } = prepareMarkdownSource("3/4 src/index.ts");
|
||
const native = toNativeLinkRecognition(linkRecognition);
|
||
expect(native.text).toMatchObject({
|
||
pattern: linkRecognition.text.source,
|
||
isDisabled: false,
|
||
isDefault: false,
|
||
});
|
||
expect(native.inlineCode).toMatchObject({
|
||
pattern: linkRecognition.inlineCode.source,
|
||
isDisabled: false,
|
||
isDefault: false,
|
||
});
|
||
});
|
||
|
||
it.each([undefined, "file:///cache/shot.png"])(
|
||
"reserves a native workspace image while acquiring or decoding %s",
|
||
(url) => {
|
||
const status = getMarkdownImageStatus({
|
||
canAcquire: true,
|
||
url,
|
||
acquisitionFailed: false,
|
||
decodeFailed: false,
|
||
decoded: false,
|
||
});
|
||
expect(status).toBe("pending");
|
||
const prepared = prepareMarkdownSource("Before  after", undefined, {
|
||
supportsSvg: false,
|
||
});
|
||
const rendered = prepared.render(new Map(), new Set(), new Set(["shot.png"]));
|
||
expect(rendered).toBe(`Before  after`);
|
||
expect(fromMarkdown(rendered).children).toMatchObject([
|
||
{
|
||
type: "paragraph",
|
||
children: [
|
||
{ type: "text", value: "Before " },
|
||
{ type: "image", url: PENDING_MARKDOWN_IMAGE },
|
||
{ type: "text", value: " after" },
|
||
],
|
||
},
|
||
]);
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
{ acquisitionFailed: true, decodeFailed: false },
|
||
{ acquisitionFailed: false, decodeFailed: true },
|
||
])("collapses confirmed native image failures to alt text: %s", (failure) => {
|
||
expect(
|
||
getMarkdownImageStatus({
|
||
canAcquire: true,
|
||
url: "file:///cache/shot.png",
|
||
decoded: false,
|
||
...failure,
|
||
}),
|
||
).toBe("unavailable");
|
||
const prepared = prepareMarkdownSource("Before  after", undefined, {
|
||
supportsSvg: false,
|
||
});
|
||
expect(prepared.render(new Map(), new Set(["shot.png"]), new Set(["shot.png"]))).toBe(
|
||
"Before image after",
|
||
);
|
||
});
|
||
|
||
it("replaces the native placeholder only after the resolved URL decodes", () => {
|
||
const url = "file:///cache/shot.png";
|
||
expect(
|
||
getMarkdownImageStatus({
|
||
canAcquire: true,
|
||
url,
|
||
acquisitionFailed: false,
|
||
decodeFailed: false,
|
||
decoded: true,
|
||
}),
|
||
).toBe("ready");
|
||
const prepared = prepareMarkdownSource("", undefined, { supportsSvg: false });
|
||
expect(prepared.render(new Map([["shot.png", url]]))).toBe(``);
|
||
});
|
||
|
||
it("makes the web Markdown container selectable in Safari as well as unprefixed browsers", () => {
|
||
expect(createMarkdownStyles(lightTheme, { blockGap: 12 }).containerStyle).toEqual({
|
||
userSelect: "text",
|
||
WebkitUserSelect: "text",
|
||
});
|
||
});
|
||
|
||
it.each([false, true])(
|
||
"scales inline code from theme tokens with its surrounding block (compact: %s)",
|
||
(compact) => {
|
||
const theme = {
|
||
...darkTheme,
|
||
fontSize: { ...darkTheme.fontSize, code: 13, content: 17, base: 16 },
|
||
};
|
||
const { markdownStyle } = createMarkdownStyles(theme, { compact, blockGap: 16 });
|
||
const bodySize = compact ? theme.fontSize.base : theme.fontSize.content;
|
||
expect(markdownStyle.code).not.toHaveProperty("fontSize");
|
||
expect(markdownStyle.code).toHaveProperty("fontScale", theme.fontSize.code / bodySize);
|
||
expect(markdownStyle.codeBlock?.fontSize).toBe(theme.fontSize.code);
|
||
},
|
||
);
|