Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/shared/src/markdownLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,25 @@ describe("isWindowsDrivePathHref", () => {
});

describe("extractMarkdownLinkHrefs", () => {
it.each([
["[".repeat(32_000), []],
["[](a".repeat(16_000), []],
["[](<a".repeat(16_000), []],
["[x](https://e.com/".repeat(8_000), []],
["[a](<foo)bar> invalid [b](ok)", ["<foo", "ok"]],
["[a](foo \"mixed title') [b](< >)", ["foo"]],
["[a](<one [b](two)> invalid [c](ok)", ["two", "ok"]],
["```text\n" + "[unfinished item\n".repeat(8_000) + "```", []],
["[[[nested](src/main.ts) [unfinished", ["src/main.ts"]],
["[outer [inner] invalid [valid](src/main.ts)", ["src/main.ts"]],
["[bad](missing title [valid](src/main.ts)", ["src/main.ts"]],
["```text\n[link](src/main.ts)\n```", ["src/main.ts"]],
["[empty]() [valid](src/main.ts 'title')", ["src/main.ts"]],
['[angle](<src/My File.ts> "title")', ["src/My File.ts"]],
])("extracts links from incomplete and nested text", (markdown, hrefs) => {
expect(extractMarkdownLinkHrefs(markdown)).toEqual(hrefs);
});

it("extracts angle-bracketed paths containing spaces", () => {
expect(
extractMarkdownLinkHrefs(
Expand Down
71 changes: 67 additions & 4 deletions packages/shared/src/markdownLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,13 +322,76 @@ export function extractInlineCodeSpans(text: string): string[] {
return spans;
}

const MARKDOWN_LINK_HREF_PATTERN =
/\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g;
/** Reuses delimiter searches when successive candidates fall in the same run. */
function markdownRunEndFinder(markdown: string, delimiter: RegExp) {
let runStart = -1;
let runEnd = -1;
return (start: number) => {
if (start >= runStart && start <= runEnd) return runEnd;
delimiter.lastIndex = start;
runStart = start;
runEnd = delimiter.exec(markdown)?.index ?? markdown.length;
return runEnd;
};
}

function markdownLinkTailScanner(markdown: string) {
const whitespaceEnd = markdownRunEndFinder(markdown, /\S/g);
const titleEnd = markdownRunEndFinder(markdown, /["']/g);
const titleWhitespaceEnd = markdownRunEndFinder(markdown, /\S/g);
return (start: number) => {
const end = whitespaceEnd(start);
if (markdown[end] === ")") return end + 1;
if (end === start || (markdown[end] !== '"' && markdown[end] !== "'")) return null;
const quoteEnd = titleEnd(end + 1);
if (quoteEnd === markdown.length) return null;
const close = titleWhitespaceEnd(quoteEnd + 1);
return markdown[close] === ")" ? close + 1 : null;
};
}

function markdownLinkDestinationScanner(markdown: string) {
const whitespaceEnd = markdownRunEndFinder(markdown, /\S/g);
const angleEnd = markdownRunEndFinder(markdown, /[>\n]/g);
const bareEnd = markdownRunEndFinder(markdown, /[\s)]/g);
// Separate caches keep each branch's searches moving forward even when an
// angle destination fails and falls back to an ordinary destination.
const angleTail = markdownLinkTailScanner(markdown);
const bareTail = markdownLinkTailScanner(markdown);
return (start: number) => {
if (markdown[start] !== "(") return null;
const hrefStart = whitespaceEnd(start + 1);
if (markdown[hrefStart] === "<") {
const hrefEnd = angleEnd(hrefStart + 1);
if (hrefEnd > hrefStart + 1 && markdown[hrefEnd] === ">") {
const end = angleTail(hrefEnd + 1);
if (end !== null) return { hrefStart: hrefStart + 1, hrefEnd, end };
}
}
const hrefEnd = bareEnd(hrefStart);
if (hrefEnd === hrefStart) return null;
const end = bareTail(hrefEnd);
return end === null ? null : { hrefStart, hrefEnd, end };
};
}

export function extractMarkdownLinkHrefs(markdown: string): string[] {
const hrefs: string[] = [];
for (const match of markdown.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
const href = (match[1] ?? match[2])?.trim();
const scanDestination = markdownLinkDestinationScanner(markdown);
let cursor = 0;
while (cursor < markdown.length) {
const labelStart = markdown.indexOf("[", cursor);
if (labelStart === -1) break;
const labelEnd = markdown.indexOf("]", labelStart + 1);
if (labelEnd === -1) break;

// Nested opening brackets share this closing bracket. On failure, skip the
// whole label instead of rescanning its suffix from each opening bracket.
cursor = labelEnd + 1;
const destination = scanDestination(cursor);
if (!destination) continue;
cursor = destination.end;
const href = markdown.slice(destination.hrefStart, destination.hrefEnd).trim();
if (href) hrefs.push(href);
}
return hrefs;
Expand Down
Loading