From 0ffb5f686399eb76fdd4f996c64ad9ef23c00677 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:33:19 +0800 Subject: [PATCH] fix(markdown): drop trailing markdown delimiters from linkified URLs (MUL-4242) (#5139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the read-only gfm-autolink approach (#5091), which split URL linkification across two engines: the editor kept the string preprocessor (urls:true) while the read-only renderer let remark-gfm autolink (urls:false) plus a remark-cjk-autolink plugin. gfm autolink still swallowed the closing `**` into the href whenever a CJK punctuation immediately followed (`**url**(MUL)`), so bold-wrapped URLs stayed broken in Chinese prose. Fix it once, at the shared string layer: collectLinkifyMatches now drops a trailing run of markdown delimiters (`*`, `~`) from each URL match, so `**url**` yields a clean `**[url](url)**` and the emphasis closes. Editor and read-only share preprocessMarkdown / preprocessLinks again — one linkify logic, no renderer-specific machinery. - linkify.ts: trailing-delimiter strip in collectLinkifyMatches; CJK rescan is keyed off the terminator index, independent of the trim. - Remove the urls:false split (detectLinks / preprocessLinks / preprocessMarkdown) and delete the remark-cjk-autolink plugin. - Tests: **url**, **url**(CJK, CJK multi-URL, explicit link untouched, and the trailing-* tradeoff. Known tradeoff: a bare URL that genuinely ends in `*` (e.g. a glob) has the `*` dropped from the link — identical to GitHub's autolink, locked by test. Co-authored-by: Claude Opus 4.8 Co-authored-by: multica-agent --- packages/ui/markdown/Markdown.tsx | 10 +- packages/ui/markdown/index.ts | 3 +- packages/ui/markdown/linkify.ts | 73 ++++++++------- packages/ui/markdown/remark-cjk-autolink.ts | 91 ------------------- .../views/editor/readonly-content.test.tsx | 29 ++++-- packages/views/editor/readonly-content.tsx | 8 +- .../editor/utils/preprocess-links.test.ts | 46 ++++++---- packages/views/editor/utils/preprocess.ts | 9 +- 8 files changed, 99 insertions(+), 170 deletions(-) delete mode 100644 packages/ui/markdown/remark-cjk-autolink.ts diff --git a/packages/ui/markdown/Markdown.tsx b/packages/ui/markdown/Markdown.tsx index 70cd40899..cff5c923f 100644 --- a/packages/ui/markdown/Markdown.tsx +++ b/packages/ui/markdown/Markdown.tsx @@ -12,7 +12,6 @@ import { CODE_LIGATURE_CLASS } from '@multica/ui/lib/code-style' import { CodeBlock, InlineCode } from './CodeBlock' import { isAllowedFileCardHref, preprocessFileCards } from './file-cards' import { preprocessLinks } from './linkify' -import { remarkCjkAutolink } from './remark-cjk-autolink' import { preprocessMentionShortcodes } from './mentions' import 'katex/dist/katex.min.css' import './markdown.css' @@ -450,15 +449,11 @@ export function Markdown({ [mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard] ) - // Preprocess: convert mention shortcodes, file paths, and file cards to - // renderable content. URLs are intentionally left bare (urls: false) so - // remark-gfm autolinks them in the parse tree — a bare URL can then no longer - // swallow an adjacent markdown delimiter like a closing `**` (MUL-4242). - // remarkCjkAutolink re-applies the CJK boundary on gfm's autolink nodes. + // Preprocess: convert mention shortcodes, raw URLs, and file cards to renderable content const processedContent = React.useMemo( () => { let result = preprocessMentionShortcodes(children) - result = preprocessLinks(result, { urls: false }) + result = preprocessLinks(result) result = preprocessFileCards(result, cdnDomain ?? '') return result }, @@ -472,7 +467,6 @@ export function Markdown({ [remarkMath, { singleDollarTextMath: false }], remarkBreaks, [remarkGfm, { singleTilde: false }], - remarkCjkAutolink, ]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]} urlTransform={urlTransform} diff --git a/packages/ui/markdown/index.ts b/packages/ui/markdown/index.ts index 98ed2afba..d11121fb2 100644 --- a/packages/ui/markdown/index.ts +++ b/packages/ui/markdown/index.ts @@ -1,8 +1,7 @@ export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from './Markdown' export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock' export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown' -export { preprocessLinks, detectLinks, hasLinks, CJK_URL_TERMINATOR_REGEX } from './linkify' -export { remarkCjkAutolink } from './remark-cjk-autolink' +export { preprocessLinks, detectLinks, hasLinks } from './linkify' export { preprocessMentionShortcodes } from './mentions' export { preprocessFileCards, diff --git a/packages/ui/markdown/linkify.ts b/packages/ui/markdown/linkify.ts index 7fee366cb..c62bae627 100644 --- a/packages/ui/markdown/linkify.ts +++ b/packages/ui/markdown/linkify.ts @@ -35,12 +35,18 @@ const BARE_FILENAME_REGEX = new RegExp(`^[\\w.-]+\\.(?:${FILE_EXTENSIONS})$`, 'i // character up to the next whitespace swallowed into the href. We truncate the // detected URL at the first occurrence of any of these characters. Character // set mirrors the fix applied in mattermost/marked#22. -// -// Exported so the read-only render pipeline can apply the same boundary to URLs -// that remark-gfm autolinks in the parse tree (see remark-cjk-autolink.ts). -export const CJK_URL_TERMINATOR_REGEX = +const CJK_URL_TERMINATOR_REGEX = /[!-/:-@[-`{-~、。「-】]/ +// Markdown inline-formatting delimiters that linkify-it counts as URL characters +// but which, at the very end of a bare URL, are almost always the author closing +// an emphasis / strikethrough span (`**url**`, `*url*`, `~~url~~`) rather than +// part of the URL. We drop a trailing run of them so the surrounding markdown +// still parses — mirroring GFM's own autolink trailing-punctuation trim. A URL +// that genuinely ends in `*` / `~` loses that character from the link, exactly +// as it does on GitHub (MUL-4242). +const TRAILING_MD_DELIMITER = /[*~]+$/ + interface DetectedLink { type: 'url' | 'email' | 'file' text: string @@ -226,9 +232,11 @@ function rangesOverlap( /** * Run linkify-it on `text` and push normalized link records into `out`, - * shifted by `offset`. When linkify-it merges multiple URLs into one match - * because they are separated only by CJK punctuation (which it doesn't treat - * as a URL boundary), we truncate at that punctuation and re-scan the tail. + * shifted by `offset`. Two boundary corrections linkify-it doesn't make itself: + * - CJK punctuation ends a URL (linkify-it only breaks on ASCII); when it merged + * several URLs across CJK punctuation we truncate and re-scan the tail; and + * - a trailing run of markdown delimiters (`*`, `~`) is dropped from the URL so + * `**url**` keeps its closing emphasis marker outside the link. */ function collectLinkifyMatches(text: string, offset: number, out: DetectedLink[]): void { const matches = linkify.match(text) @@ -239,33 +247,40 @@ function collectLinkifyMatches(text: string, offset: number, out: DetectedLink[] if (cjkIdx === 0) continue // match starts with CJK punct — skip const truncate = cjkIdx > 0 - const matchText = truncate ? match.text.slice(0, cjkIdx) : match.text - const matchEnd = truncate ? match.index + cjkIdx : match.lastIndex + // URL text up to the first CJK terminator, then minus a trailing markdown + // delimiter run (e.g. the closing `**` of `**url**`). matchText stays a + // prefix of match.text, so the link end is just its length past match.index. + const matchText = (truncate ? match.text.slice(0, cjkIdx) : match.text).replace( + TRAILING_MD_DELIMITER, + '' + ) // Bare filenames such as "plan.md" or "README.md" are fuzzy-matched as // domains because their extension is also a valid TLD. They are file // references, not URLs — leave them as plain text rather than link to a // dead external site. Only schemeless (fuzzy) matches are suppressed; an // explicit "https://plan.md" the author typed is still honored. - if (!(match.schema === '' && BARE_FILENAME_REGEX.test(matchText))) { - // linkify-it may prepend a scheme (e.g. "http://" or "mailto:") to url - // while leaving text as the raw substring. Preserve that prefix. + if (matchText.length > 0 && !(match.schema === '' && BARE_FILENAME_REGEX.test(matchText))) { + // When we trimmed the raw match, rebuild the href from the scheme prefix + // linkify-it added (e.g. "http://" / "mailto:") plus the trimmed text; + // otherwise keep linkify-it's normalized url as-is. + const trimmed = matchText.length !== match.text.length const schemePrefix = match.url.slice(0, match.url.length - match.text.length) - const matchUrl = truncate ? schemePrefix + matchText : match.url + const matchUrl = trimmed ? schemePrefix + matchText : match.url out.push({ type: match.schema === 'mailto:' ? 'email' : 'url', text: matchText, url: matchUrl, start: match.index + offset, - end: matchEnd + offset + end: match.index + matchText.length + offset }) } if (truncate) { // Rescan the tail after the CJK punct — linkify-it had greedily swallowed // it, so any additional URLs after the punct were never emitted. - const tailStart = matchEnd + 1 + const tailStart = match.index + cjkIdx + 1 collectLinkifyMatches(text.slice(tailStart), offset + tailStart, out) return } @@ -274,17 +289,12 @@ function collectLinkifyMatches(text: string, offset: number, out: DetectedLink[] /** * Detect all links (URLs, emails, file paths) in text. - * - * `includeUrls` gates the URL/email pass. Read-only markdown renderers pass - * `false` and let remark-gfm autolink URLs in the parse tree instead, which - * cannot corrupt adjacent markdown (e.g. a trailing `**`). File paths, which - * remark-gfm does not linkify, are always detected. See preprocessLinks. */ -export function detectLinks(text: string, includeUrls = true): DetectedLink[] { +export function detectLinks(text: string): DetectedLink[] { const links: DetectedLink[] = [] - // 1. Detect URLs and emails with linkify-it, applying CJK boundary handling. - if (includeUrls) collectLinkifyMatches(text, 0, links) + // 1. Detect URLs and emails with linkify-it, applying boundary corrections. + collectLinkifyMatches(text, 0, links) // 2. Detect file paths with custom regex // Reset regex state @@ -321,17 +331,12 @@ export function detectLinks(text: string, includeUrls = true): DetectedLink[] { * Preprocess text to convert raw URLs and file paths into markdown links. * Skips code blocks and already-linked content. * - * `opts.urls` (default `true`) controls the URL/email pass. The Tiptap editor - * keeps it on because @tiptap/markdown does not autolink bare URLs. Read-only - * react-markdown renderers pass `false`: they let remark-gfm autolink URLs in - * the parse tree, where a bare URL can no longer swallow an adjacent markdown - * delimiter — the string pass here can't tell `https://x**` (URL + bold close) - * from a URL that legitimately ends in `*`, so it corrupted both (MUL-4242). - * File paths (which remark-gfm never linkifies) are converted in both modes. + * Shared by the Tiptap editor and the read-only react-markdown renderers, so + * both surfaces linkify identically. Trailing markdown delimiters are excluded + * from the URL (see collectLinkifyMatches), which keeps `**url**` bold and its + * link clean (MUL-4242). */ -export function preprocessLinks(text: string, opts?: { urls?: boolean }): string { - const includeUrls = opts?.urls ?? true - +export function preprocessLinks(text: string): string { // Quick check - if no potential links, return early if (!linkify.pretest(text) && !/[~/.]\//.test(text)) { return text @@ -339,7 +344,7 @@ export function preprocessLinks(text: string, opts?: { urls?: boolean }): string const codeRanges = findCodeRanges(text) const markdownLinkRanges = findMarkdownLinkRanges(text) - const links = detectLinks(text, includeUrls) + const links = detectLinks(text) if (links.length === 0) return text diff --git a/packages/ui/markdown/remark-cjk-autolink.ts b/packages/ui/markdown/remark-cjk-autolink.ts deleted file mode 100644 index 5e54a3cee..000000000 --- a/packages/ui/markdown/remark-cjk-autolink.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { CJK_URL_TERMINATOR_REGEX, detectLinks } from './linkify' - -/** - * remark-cjk-autolink — trim CJK punctuation that remark-gfm's autolink literal - * swallowed into a URL. - * - * Read-only renderers let remark-gfm autolink bare URLs in the parse tree, so an - * adjacent markdown delimiter (e.g. a closing `**`) is never absorbed into the - * href — that was MUL-4242. gfm's autolink literal, however, shares linkify-it's - * CJK weakness: `https://x/a。后面` extends the link across the ideographic full - * stop and the run after it, and `url1、url2` gets glued into one link. - * preprocessLinks used to trim this before parsing; since URLs are no longer - * preprocessed in read-only mode, we re-derive the real segments on the parsed - * tree with the same CJK-aware detector, which rescans the tail so every URL in - * a CJK-separated run stays linked. - * - * Only autolink *literals* are touched — links whose href was derived from the - * visible text (`https://…`, `www.…` → `http://…`, `a@b` → `mailto:a@b`). - * Explicit `[label](url)` links keep whatever destination the author wrote, even - * when it contains CJK punctuation. - */ - -interface MdNode { - type: string - url?: string - value?: string - children?: MdNode[] -} - -// The scheme prefix remark-gfm prepends to an autolink literal's href. Returns -// null when `url` was not derived from `text`, i.e. an explicit link — leave it. -function autolinkSchemePrefix(url: string, text: string): string | null { - if (url === text) return '' - for (const prefix of ['http://', 'https://', 'mailto:']) { - if (url === prefix + text) return prefix - } - return null -} - -// If `node` is an autolink literal whose text ran past a CJK terminator, rebuild -// it: gfm glued everything up to the next whitespace into one link, so re-derive -// the real segments and return the [link, text, link, …] sequence to splice in. -// Returns null (leave the node alone) when it is not an autolink literal or its -// boundary was already correct. -function splitCjkAutolink(node: MdNode): MdNode[] | null { - if (node.type !== 'link' || !node.url || node.children?.length !== 1) return null - const child = node.children[0] - if (!child || child.type !== 'text' || typeof child.value !== 'string') return null - - const text = child.value - if (autolinkSchemePrefix(node.url, text) === null) return null // not an autolink literal - if (!CJK_URL_TERMINATOR_REGEX.test(text)) return null // gfm's boundary was already correct - - // detectLinks reuses collectLinkifyMatches, which truncates each URL at the - // first CJK terminator AND rescans the tail — so multiple URLs separated by - // CJK punctuation (`url1、url2`) each come back as their own segment instead - // of only the first staying linked. - const links = detectLinks(text, true).filter((link) => link.type !== 'file') - if (links.length === 0) return null - - const out: MdNode[] = [] - let pos = 0 - for (const link of links) { - if (link.start > pos) out.push({ type: 'text', value: text.slice(pos, link.start) }) - out.push({ ...node, url: link.url, children: [{ type: 'text', value: link.text }] }) - pos = link.end - } - if (pos < text.length) out.push({ type: 'text', value: text.slice(pos) }) - return out -} - -function transform(node: MdNode): void { - const children = node.children - if (!children) return - for (let i = 0; i < children.length; i++) { - const split = splitCjkAutolink(children[i]!) - if (split) { - children.splice(i, 1, ...split) - i += split.length - 1 // skip the appended trailing-text node - } else { - transform(children[i]!) - } - } -} - -/** unified/remark plugin. Attach after remark-gfm. */ -export function remarkCjkAutolink() { - return (tree: unknown): void => { - transform(tree as MdNode) - } -} diff --git a/packages/views/editor/readonly-content.test.tsx b/packages/views/editor/readonly-content.test.tsx index f9fd0c7de..da3993961 100644 --- a/packages/views/editor/readonly-content.test.tsx +++ b/packages/views/editor/readonly-content.test.tsx @@ -618,11 +618,11 @@ describe("ReadonlyContent slash command rendering", () => { }); describe("ReadonlyContent bare URL autolinking (MUL-4242)", () => { - // A bare URL wrapped in bold used to be pre-linkified into [url**](url**), - // which swallowed the closing `**`: the bold never closed (leading `**` - // showed as literal asterisks) and the href was corrupted with a trailing - // `**`. URLs are now autolinked by remark-gfm in the parse tree — after - // emphasis is resolved — so an adjacent delimiter can no longer be absorbed. + // A bare URL wrapped in bold used to be linkified into [url**](url**), which + // swallowed the closing `**`: the bold never closed (leading `**` showed as + // literal asterisks) and the href was corrupted with a trailing `**`. The + // shared linkify now drops a trailing markdown-delimiter run from the URL, so + // the closing `**` stays as emphasis outside a clean [url](url). it("renders a bold-wrapped bare URL as bold plus a clean link", () => { const url = "https://github.com/multica-ai/multica/pull/5081"; const { container } = render(); @@ -636,6 +636,21 @@ describe("ReadonlyContent bare URL autolinking (MUL-4242)", () => { expect(anchor?.getAttribute("href")).not.toContain("*"); }); + it("bolds a bare URL even when a CJK punctuation immediately follows (variant B)", () => { + // `**url**(MUL)` — the closing `**` is glued to a fullwidth paren. gfm + // autolink swallowed the `**` here; the shared string linkify does not. + const url = "https://github.com/multica-ai/multica/pull/5133"; + const { container } = render( + , + ); + + const strong = container.querySelector("strong"); + expect(strong).not.toBeNull(); + expect(strong!.querySelector("a")?.getAttribute("href")).toBe(url); + expect(container.textContent).not.toContain("**"); + expect(container.textContent).toContain("(MUL-4277)"); + }); + it("still autolinks a plain bare URL", () => { const { container } = render( , @@ -655,8 +670,8 @@ describe("ReadonlyContent bare URL autolinking (MUL-4242)", () => { }); it("keeps every URL in a CJK-separated run linked, not just the first", () => { - // Regression: gfm glues `url1、url2` into one autolink; trimming only at the - // first 、 left url2 as plain text. The tail must be rescanned so both link. + // `url1、url2` — linkify-it merges both across the CJK comma; collectLinkify + // truncates at 、 and rescans the tail so both URLs become their own link. const { container } = render( , ); diff --git a/packages/views/editor/readonly-content.tsx b/packages/views/editor/readonly-content.tsx index d3b5e8558..8952e9959 100644 --- a/packages/views/editor/readonly-content.tsx +++ b/packages/views/editor/readonly-content.tsx @@ -41,7 +41,7 @@ import { IssueMentionCard } from "../issues/components/issue-mention-card"; import { ProjectChip } from "../projects/components/project-chip"; import { useLinkHover, LinkHoverCard } from "./link-hover-card"; import { openLink, isMentionHref } from "./utils/link-handler"; -import { isAllowedFileCardHref, remarkCjkAutolink } from "@multica/ui/markdown"; +import { isAllowedFileCardHref } from "@multica/ui/markdown"; import { preprocessMarkdown } from "./utils/preprocess"; import { highlightToHtml } from "./utils/highlight-markdown"; import { MermaidDiagram } from "./mermaid-diagram"; @@ -432,11 +432,8 @@ export const ReadonlyContent = memo(function ReadonlyContent({ className, attachments, }: ReadonlyContentProps) { - // urls: false — let remark-gfm autolink bare URLs in the parse tree so an - // adjacent markdown delimiter (e.g. a closing `**`) is never swallowed into - // the href (MUL-4242). remarkCjkAutolink below re-applies the CJK boundary. const processed = useMemo( - () => highlightToHtml(preprocessMarkdown(content, { urls: false })), + () => highlightToHtml(preprocessMarkdown(content)), [content], ); const wrapperRef = useRef(null); @@ -461,7 +458,6 @@ export const ReadonlyContent = memo(function ReadonlyContent({ [remarkMath, { singleDollarTextMath: false }], remarkBreaks, [remarkGfm, { singleTilde: false }], - remarkCjkAutolink, ]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]} urlTransform={urlTransform} diff --git a/packages/views/editor/utils/preprocess-links.test.ts b/packages/views/editor/utils/preprocess-links.test.ts index 376d58678..db9a62623 100644 --- a/packages/views/editor/utils/preprocess-links.test.ts +++ b/packages/views/editor/utils/preprocess-links.test.ts @@ -137,32 +137,44 @@ describe("preprocessLinks — bare filenames are not auto-linked as URLs", () => }); }); -// Read-only react-markdown renderers pass { urls: false } and let remark-gfm -// autolink URLs in the parse tree instead, so a bare URL can no longer swallow -// an adjacent markdown delimiter like a closing ** (MUL-4242). File paths, which -// remark-gfm never linkifies, are still converted. -describe("preprocessLinks — urls:false (read-only mode)", () => { - it("leaves bare URLs untouched so remark-gfm can autolink them", () => { - expect(preprocessLinks("see https://example.com/x here", { urls: false })).toBe( - "see https://example.com/x here", +// Trailing markdown emphasis / strikethrough delimiters that linkify-it counts +// as URL characters must be dropped from the URL, so the closing `**` of +// `**url**` stays as emphasis instead of being swallowed into the href — that +// swallow was the MUL-4242 render bug. Mirrors GFM's own autolink trailing trim. +describe("preprocessLinks — trailing markdown delimiter is not part of the URL", () => { + it("keeps the closing ** outside a bold-wrapped bare URL", () => { + expect(preprocessLinks("**https://example.com/x**")).toBe( + "**[https://example.com/x](https://example.com/x)**", ); }); - it("does not rewrite a bold-wrapped URL into [url**](url**) (the root cause)", () => { - expect(preprocessLinks("**PR:https://example.com/x**", { urls: false })).toBe( - "**PR:https://example.com/x**", + it("handles the original bug shape (bold + fullwidth colon)", () => { + expect(preprocessLinks("**PR:https://example.com/x**")).toBe( + "**PR:[https://example.com/x](https://example.com/x)**", ); }); - it("still linkifies explicit ./ file paths", () => { - expect(preprocessLinks("see ./src/main.go here", { urls: false })).toBe( - "see [./src/main.go](./src/main.go) here", + it("keeps ** outside when a CJK punctuation immediately follows (variant B)", () => { + expect(preprocessLinks("**https://example.com/x**(MUL-4277)")).toBe( + "**[https://example.com/x](https://example.com/x)**(MUL-4277)", ); }); - it("default mode still linkifies URLs (editor path unchanged)", () => { - expect(preprocessLinks("see https://example.com/x here")).toBe( - "see [https://example.com/x](https://example.com/x) here", + it("keeps the closing * outside an italic-wrapped bare URL", () => { + expect(preprocessLinks("*https://example.com/x*")).toBe( + "*[https://example.com/x](https://example.com/x)*", + ); + }); + + it("strips a trailing * that really ends a URL — documented tradeoff, matches GFM", () => { + expect(preprocessLinks("see https://example.com/glob/* here")).toBe( + "see [https://example.com/glob/](https://example.com/glob/)* here", + ); + }); + + it("keeps a * in the middle of a URL", () => { + expect(preprocessLinks("https://example.com/a*b/c")).toBe( + "[https://example.com/a*b/c](https://example.com/a*b/c)", ); }); }); diff --git a/packages/views/editor/utils/preprocess.ts b/packages/views/editor/utils/preprocess.ts index 01fdcb282..b41391a9c 100644 --- a/packages/views/editor/utils/preprocess.ts +++ b/packages/views/editor/utils/preprocess.ts @@ -15,15 +15,14 @@ import { configStore } from "@multica/core/config"; * 3. File card syntax (new !file[name](url) + legacy [name](cdnUrl)) → HTML div for * fileCard node parsing * - * `opts.urls` (default `true`) forwards to preprocessLinks. The Tiptap editor - * needs it on; read-only react-markdown surfaces pass `false` and let remark-gfm - * autolink URLs in the parse tree instead (MUL-4242). See preprocessLinks. + * Shared by the Tiptap editor and the read-only react-markdown renderer so both + * linkify identically. */ -export function preprocessMarkdown(markdown: string, opts?: { urls?: boolean }): string { +export function preprocessMarkdown(markdown: string): string { if (!markdown) return ""; const cdnDomain = configStore.getState().cdnDomain; const step1 = preprocessMentionShortcodes(markdown); - const step2 = preprocessLinks(step1, opts); + const step2 = preprocessLinks(step1); const step3 = preprocessFileCards(step2, cdnDomain); return step3; }