mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-12 19:35:04 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65f02850b | ||
|
|
2648b4582e | ||
|
|
c25052bc6c | ||
|
|
afda258425 | ||
|
|
94107c70c5 | ||
|
|
a266c1a9ae |
@@ -792,6 +792,16 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"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"),
|
||||
});
|
||||
|
||||
@@ -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\* <img></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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user