Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] c65f02850b fix(app): preserve embedded Markdown containers during HTML normalization 2026-11-11 14:11:52 +00:00
paseo-bot[bot] 2648b4582e fix(app): preserve tag-leading code inside preformatted HTML 2026-11-11 14:01:40 +00:00
paseo-bot[bot]andgengjiawen c25052bc6c fix(app): load images from indented HTML tables without losing Markdown indentation
Keep the fix in HTML normalization: remove tag-leading layout indentation
while preserving Markdown list depth and preformatted text. The separate
image-row renderer is unnecessary for restoring image loading.

Co-authored-by: gengjiawen <technicalcute@gmail.com>
2026-11-11 13:55:58 +00:00
paseo-bot[bot] afda258425 Merge remote-tracking branch 'origin/main' into diagnose/9391-c51458dd 2026-11-11 13:51:43 +00:00
gengjiawen 94107c70c5 fix(app): lay out HTML image rows side by side in Markdown
A README screenshot table (an HTML block of <img width="170"> cells) rendered
as a column of full-width images: the Markdown engine has no per-image size
and treats every image on its own line as a full-width block. GitHub shows the
cells side by side at their declared width.

Top-level HTML blocks that hold only images (tables, centered badge rows,
<picture>) now render outside the engine as rows of images at their declared
width and height, wrapping when the pane is narrower than the row. Linked
images open their link; others open the lightbox. Local images load through
the same daemon path as Markdown images. Blocks with any text, headings or
other elements, and blocks nested in lists or quotes, stay with the engine.
2026-11-11 19:50:17 +08:00
gengjiawen a266c1a9ae fix(app): render images in indented HTML tables in Markdown
README screenshot rows are usually written as an HTML table with each
<td> indented on its own line. Converting the tags to Markdown kept that
indentation, so every image line started with four spaces and rendered
as an indented code block showing the raw `![alt](src)` text. Local
images inside it were never collected, so they never loaded.

HTML ignores indentation between tags. Inside HTML blocks, keep only the
quote or list prefix of each line and drop the rest, leaving code spans,
fences and <pre> content unchanged.
2026-11-11 19:04:52 +08:00
3 changed files with 222 additions and 10 deletions
@@ -792,6 +792,16 @@ test.describe("CodeMirror workspace file editing", () => {
"",
"![hero](../data/cache/hero.png?v=2#preview)",
"",
"Screenshots",
"",
// Nested HTML indentation must not turn the image markup into a code block.
"<table>",
" <tr>",
' <td><img src="sibling.png" width="120" alt="table sibling"></td>',
' <td><img src="../data/cache/hero.png" width="120" alt="table hero"></td>',
" </tr>",
"</table>",
"",
].join("\n"),
"utf8",
);
@@ -805,6 +815,8 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
await expectMarkdownImageLoaded(page, "sibling");
await expectMarkdownImageLoaded(page, "hero");
await expectMarkdownImageLoaded(page, "table sibling");
await expectMarkdownImageLoaded(page, "table hero");
await visibleFilePane.screenshot({
path: test.info().outputPath("markdown-relative-images.png"),
});
+76 -10
View File
@@ -9,6 +9,12 @@ const HTML = new RegExp(
String.raw`(?<!\\)(?:<!--[\s\S]*?-->|<code\b[^>]*>[\s\S]*?<\/code\s*>|<summary\b[^>]*>[\s\S]*?<\/summary\s*>|${TAG})`,
"gi",
);
// HTML tag-leading lines carry layout indentation, unlike embedded Markdown text.
const HTML_OR_LINE_INDENT = new RegExp(
String.raw`${HTML.source}|(?:^|(?<=[\r\n]))[ \t>]+(?=<)`,
"gi",
);
const FLOW_CONTAINERS = new Set(["root", "blockquote", "listItem"]);
const BLOCK_TAGS = new Set([
"div",
"p",
@@ -30,33 +36,86 @@ const BLOCK_TAGS = new Set([
]);
const EMPHASIS: Readonly<Record<string, string>> = { b: "**", strong: "**", i: "*", em: "*" };
interface ContainerIndent {
start: number;
end: number;
indent: number;
}
function containerIndentAt(
offset: number,
outerIndent: number,
containers: ContainerIndent[],
): number {
let indent = outerIndent;
for (const container of containers) {
if (offset >= container.start && offset < container.end)
indent = Math.max(indent, container.indent);
}
return indent;
}
/** Convert comment HTML to Markdown while preserving the original spelling of code. */
export function normalizeMarkdownHtml(source: string): string {
if (!source.includes("<")) return source;
const htmlRanges: { start: number; end: number }[] = [];
function findHtml(node: Nodes) {
// `indent` is the container prefix width of an HTML block's lines; inline HTML has none.
const htmlRanges: { start: number; end: number; indent?: number }[] = [];
function findHtml(node: Nodes, block: boolean) {
if (node.type === "html" && node.position) {
htmlRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
} else if ("children" in node) node.children.forEach(findHtml);
htmlRanges.push({
start: node.position.start.offset!,
end: node.position.end.offset!,
// Whitespace inside <pre> is content.
indent:
block && !/^\s*<pre\b/i.test(node.value) ? node.position.start.column - 1 : undefined,
});
} else if ("children" in node)
node.children.forEach((child) => findHtml(child, FLOW_CONTAINERS.has(node.type)));
}
findHtml(fromMarkdown(source));
findHtml(fromMarkdown(source), false);
const codeRanges: { start: number; end: number }[] = [];
const markdownContainers: ContainerIndent[] = [];
// Disable HTML parsing so code inside <details> remains visible to the Markdown parser.
const tree = fromMarkdown(source, {
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }],
});
function protectCode(node: Nodes) {
function readMarkdownContext(node: Nodes) {
if (node.type === "code" || node.type === "inlineCode") {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) codeRanges.push({ start, end });
} else if ("children" in node) node.children.forEach(protectCode);
}
if ((node.type === "listItem" || node.type === "blockquote") && node.position) {
const position = node.position;
const content = node.children[0]?.position;
// Outer containers already belong to the HTML node. Only discover Markdown
// containers that actually begin inside that HTML block, not earlier lists
// that the HTML-disabled parse might treat as lazy continuations.
const html = htmlRanges.find(
(range) =>
range.indent !== undefined &&
position.start.offset! >= range.start &&
position.start.offset! < range.end,
);
if (html && content)
markdownContainers.push({
start: Math.max(html.start, position.start.offset! - position.start.column + 1),
end: Math.min(html.end, position.end.offset!),
indent: content.start.column - 1,
});
}
if ("children" in node) node.children.forEach(readMarkdownContext);
}
protectCode(tree);
readMarkdownContext(tree);
const links: (string | undefined)[] = [];
let previousTag: { name: string; end: number } | null = null;
const convert = (raw: string, offset: number): string => {
let preDepth = 0;
const convert = (raw: string, offset: number, indent: number): string => {
if (codeRanges.some(({ start, end }) => offset >= start && offset < end)) return raw;
// Removing tags must not leave layout spaces that start Markdown code.
// Preserve the quote/list prefix and all indentation on Markdown text lines.
if (!raw.startsWith("<"))
return preDepth > 0 ? raw : raw.slice(0, indent) + raw.slice(indent).replace(/^[ \t]+/, "");
if (raw.startsWith("<!--")) return "";
const summary = /^<summary\b[^>]*>([\s\S]*?)<\/summary\s*>$/i.exec(raw);
if (summary) {
@@ -77,6 +136,7 @@ export function normalizeMarkdownHtml(source: string): string {
);
const closing = raw.startsWith("</");
const name = /^<\/?([\w-]+)/.exec(raw)?.[1].toLowerCase() ?? "";
if (name === "pre") preDepth = Math.max(0, preDepth + (closing ? -1 : 1));
// Each gap is inspected once, rather than searching the complete prefix
// for a preceding tag on every table row or nested block.
const startsAfterBoundary = previousTag
@@ -104,7 +164,13 @@ export function normalizeMarkdownHtml(source: string): string {
const end = paired ? Math.max(range.end, range.start + paired[0].length) : range.end;
result += source
.slice(range.start, end)
.replace(HTML, (raw, offset) => convert(raw, range.start + offset));
.replace(range.indent === undefined ? HTML : HTML_OR_LINE_INDENT, (raw, offset) =>
convert(
raw,
range.start + offset,
containerIndentAt(range.start + offset, range.indent ?? 0, markdownContainers),
),
);
cursor = end;
}
return result + source.slice(cursor);
@@ -779,6 +779,99 @@ describe("GitHub comment HTML normalization", () => {
});
});
describe("indented HTML blocks", () => {
// A README screenshot row: GitHub ignores the indentation of the nested tags.
const gallery = [
"<table>",
" <tr>",
' <td><img src="shots/one.png" width="120" alt="One"></td>',
' <td><img src="shots/two.png" width="120" alt="Two"></td>',
" </tr>",
"</table>",
];
const urls = new Map([
["shots/one.png", "blob:one"],
["shots/two.png", "blob:two"],
]);
function blocks(node: ASTNode): unknown {
if (node.type === "Paragraph" || node.type === "CodeBlock") {
const image = node.children?.find((child) => child.type === "Image");
return image ? `Image ${image.attributes?.url}` : node.type;
}
return [node.type, ...(node.children ?? []).map(blocks)];
}
it.each([
["a standalone table", gallery.join("\n"), ["Document", "Image blob:one", "Image blob:two"]],
[
"after a list",
["- Feature", "", ...gallery].join("\n"),
[
"Document",
["UnorderedList", ["ListItem", "Paragraph"]],
"Image blob:one",
"Image blob:two",
],
],
[
"the document",
["Intro", "", ...gallery].join("\n"),
["Document", "Paragraph", "Image blob:one", "Image blob:two"],
],
[
"a list item",
["- Shots", ...gallery.map((line) => ` ${line}`), "- Next"].join("\n"),
[
"Document",
[
"UnorderedList",
["ListItem", "Paragraph", "Image blob:one", "Image blob:two"],
["ListItem", "Paragraph"],
],
],
],
])("renders the images of an indented HTML table in %s", async (_name, source, expected) => {
const prepared = prepareMarkdownSource(source);
expect(prepared.images.map((image) => image.source)).toEqual([
"shots/one.png",
"shots/two.png",
]);
const tree = await parseMarkdown(prepared.render(urls), {
hardSoftBreaks: true,
latexMath: false,
});
expect(blocks(tree)).toEqual(expected);
});
it("keeps HTML table images inside their blockquote", async () => {
const prepared = prepareMarkdownSource(gallery.map((line) => `> ${line}`).join("\n"));
expect(prepared.images.map((image) => image.source)).toEqual([
"shots/one.png",
"shots/two.png",
]);
const tree = await parseMarkdown(prepared.render(urls), {
hardSoftBreaks: true,
latexMath: false,
});
expect(tree.children?.every((node) => node.type === "Blockquote")).toBe(true);
expect(tree.children?.flatMap((node) => node.children ?? []).map(blocks)).toEqual([
"Image blob:one",
"Image blob:two",
]);
});
it.each([
[
"a fence",
"<details><summary>Code</summary>\n```js\n indented();\n```\n</details>",
"**Code**\n\n\n```js\n indented();\n```\n",
],
["a pre block", "<pre>\n\n kept\n</pre>", "\n\n kept\n"],
])("keeps the indentation of %s inside HTML", (_name, source, expected) => {
expect(prepareMarkdownSource(source).render()).toBe(expected);
});
});
it("preserves backslashes and decodes entities inside HTML code", () => {
const source = String.raw`<code>\*literal\* &lt;img&gt;</code>`;
expect(prepareMarkdownSource(source).render()).toBe("`" + String.raw`\*literal\* <img>` + "`");
@@ -1096,3 +1189,44 @@ it.each([false, true])(
expect(markdownStyle.codeBlock?.fontSize).toBe(theme.fontSize.code);
},
);
describe("HTML indentation preserves Markdown content", () => {
it("keeps nested Markdown list depth inside details", () => {
const source = "<details>\n<summary>Steps</summary>\n- Parent\n - Child\n</details>";
const tree = fromMarkdown(normalizeMarkdownHtml(source));
const list = tree.children.find((node) => node.type === "list");
expect(list?.children).toHaveLength(1);
expect(list?.children[0].children[1]?.type).toBe("list");
});
it("keeps nested preformatted text indentation", () => {
const source = "<div><pre>\n first\n second\n</pre></div>";
const tree = fromMarkdown(normalizeMarkdownHtml(source));
expect(tree.children).toMatchObject([{ type: "code", value: "first\n second" }]);
});
});
it("keeps code-tag lines preformatted inside nested HTML", () => {
const source = "<div><pre>\n <code>first</code>\n second\n</pre></div>";
const tree = fromMarkdown(normalizeMarkdownHtml(source));
expect(tree.children).toMatchObject([{ type: "code", value: "`first`\n second" }]);
});
it.each([
["an HTML image", ' <img src="a.png" alt="Shot">', "Image"],
["quoted HTML text", " > <b>quoted</b>", "Blockquote"],
])("keeps %s in its Markdown list item inside details", async (_name, line, expectedType) => {
const prepared = prepareMarkdownSource(
["<details>", "<summary>Steps</summary>", "- Parent", line, "</details>"].join("\n"),
);
const tree = await parseMarkdown(prepared.render(new Map([["a.png", "blob:shot"]])), {
hardSoftBreaks: true,
latexMath: false,
});
const list = tree.children?.find((node) => node.type === "UnorderedList");
expect(list?.children).toHaveLength(1);
function descendants(node: ASTNode): ASTNode[] {
return [node, ...(node.children ?? []).flatMap(descendants)];
}
expect(list?.children?.flatMap(descendants).map((node) => node.type)).toContain(expectedType);
});