mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 19:05:16 +00:00
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 `` 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.
This commit is contained in:
@@ -792,6 +792,14 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
// README screenshot rows indent their cells; GitHub ignores that indentation.
|
||||
"<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 +813,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"),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ const HTML = new RegExp(
|
||||
String.raw`(?<!\\)(?:<!--[\s\S]*?-->|<code\b[^>]*>[\s\S]*?<\/code\s*>|<summary\b[^>]*>[\s\S]*?<\/summary\s*>|${TAG})`,
|
||||
"gi",
|
||||
);
|
||||
// Inside HTML blocks, also match each line's container prefix and indentation.
|
||||
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",
|
||||
@@ -33,13 +36,21 @@ const EMPHASIS: Readonly<Record<string, string>> = { b: "**", strong: "**", i: "
|
||||
/** 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 }[] = [];
|
||||
// Disable HTML parsing so code inside <details> remains visible to the Markdown parser.
|
||||
const tree = fromMarkdown(source, {
|
||||
@@ -55,8 +66,13 @@ export function normalizeMarkdownHtml(source: string): string {
|
||||
protectCode(tree);
|
||||
const links: (string | undefined)[] = [];
|
||||
let previousTag: { name: string; end: number } | null = null;
|
||||
const convert = (raw: string, offset: number): string => {
|
||||
const convert = (raw: string, offset: number, indent = 0): string => {
|
||||
if (codeRanges.some(({ start, end }) => offset >= start && offset < end)) return raw;
|
||||
// HTML ignores the indentation of nested tags, but once the tags are gone
|
||||
// four leading spaces would start a Markdown code block. Keep the prefix that
|
||||
// places the line in its quote or list item and drop the rest.
|
||||
if (!raw.startsWith("<"))
|
||||
return 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) {
|
||||
@@ -104,7 +120,9 @@ 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, range.indent),
|
||||
);
|
||||
cursor = end;
|
||||
}
|
||||
return result + source.slice(cursor);
|
||||
|
||||
@@ -779,6 +779,71 @@ 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([
|
||||
[
|
||||
"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.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\* <img></code>`;
|
||||
expect(prepareMarkdownSource(source).render()).toBe("`" + String.raw`\*literal\* <img>` + "`");
|
||||
|
||||
Reference in New Issue
Block a user