| author | |
| committer | |
| log | 741377ce5fb1578916659cbb93fd403b26dbeb62 |
| tree | bfe586581bd1245d4c859540b15f6f8bf47a1a7c |
| parent | e38b6a188fafd499df347331f8c8279ff320c207 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>11 files changed, 245 insertions(+), 25 deletions(-)
apps/server/src/http.test.ts+2-2| ... | ... | @@ -674,14 +674,14 @@ describe("assetResponseHeaders", () => { |
| 674 | 674 | assetResponseHeaders("/attachments/upload.bin", { mimeType: "text/html" }), |
| 675 | 675 | ).toMatchObject({ |
| 676 | 676 | "Content-Type": "text/html; charset=utf-8", |
| 677 | "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups", | |
| 677 | "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads", | |
| 678 | 678 | }); |
| 679 | 679 | }); |
| 680 | 680 | it("serves HTML assets as utf-8 inside a sandboxed origin", () => { |
| 681 | 681 | for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) { |
| 682 | 682 | expect(assetResponseHeaders(path)).toMatchObject({ |
| 683 | 683 | "Content-Type": "text/html; charset=utf-8", |
| 684 | "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups", | |
| 684 | "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads", | |
| 685 | 685 | }); |
| 686 | 686 | } |
| 687 | 687 | }); |
apps/server/src/http.ts+4-2| ... | ... | @@ -57,8 +57,10 @@ const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inlin |
| 57 | 57 | // opaque origin: scripts run, but same-origin cookies, storage, and API calls are |
| 58 | 58 | // out of reach. Relative sibling assets still load through their signed URLs. |
| 59 | 59 | // No modals: agent HTML can open without a click (inline renders, and mobile |
| 60 | // loads it as the top document), and must not raise blocking dialogs. | |
| 61 | const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups"; | |
| 60 | // loads it as the top document), and must not raise blocking dialogs. Downloads | |
| 61 | // stay allowed so download links and buttons in the page work. | |
| 62 | const HTML_CONTENT_SECURITY_POLICY = | |
| 63 | "sandbox allow-scripts allow-forms allow-popups allow-downloads"; | |
| 62 | 64 | |
| 63 | 65 | // Types a browser may render as a document if a proxy strips the disposition |
| 64 | 66 | // header. Downloads of these fall back to octet-stream. |
apps/web/src/components/ChatMarkdown.test.tsx+78-4| ... | ... | @@ -871,15 +871,15 @@ describe("ChatMarkdown heading levels", () => { |
| 871 | 871 | />, |
| 872 | 872 | ); |
| 873 | 873 | |
| 874 | expect(html).toContain('<h1 aria-level="4">Top</h1>'); | |
| 875 | expect(html).toContain('<h2 aria-level="5">Section</h2>'); | |
| 876 | expect(html).toContain('<h6 aria-level="6">Fine print</h6>'); | |
| 874 | expect(html).toContain('<h1 id="user-content-top" aria-level="4">Top</h1>'); | |
| 875 | expect(html).toContain('<h2 id="user-content-section" aria-level="5">Section</h2>'); | |
| 876 | expect(html).toContain('<h6 id="user-content-fine-print" aria-level="6">Fine print</h6>'); | |
| 877 | 877 | }); |
| 878 | 878 | |
| 879 | 879 | it("leaves heading levels alone when the markdown is not nested", () => { |
| 880 | 880 | const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text="# Top" />); |
| 881 | 881 | |
| 882 | expect(html).toContain("<h1>Top</h1>"); | |
| 882 | expect(html).toContain('<h1 id="user-content-top">Top</h1>'); | |
| 883 | 883 | }); |
| 884 | 884 | }); |
| 885 | 885 | |
| ... | ... | @@ -1272,3 +1272,77 @@ it.each([ |
| 1272 | 1272 | } |
| 1273 | 1273 | }, |
| 1274 | 1274 | ); |
| 1275 | ||
| 1276 | describe("ChatMarkdown heading ids", () => { | |
| 1277 | it("never gives two headings the same id, even when a suffix matches another heading", () => { | |
| 1278 | const html = renderToStaticMarkup( | |
| 1279 | <ChatMarkdown | |
| 1280 | cwd="/tmp/project" | |
| 1281 | parseRawHtml | |
| 1282 | text={ | |
| 1283 | '## Setup\n\n## Setup\n\n## Setup-1\n\n<h2 id="install-1">Pinned</h2>\n\n## Install\n\n## Install' | |
| 1284 | } | |
| 1285 | />, | |
| 1286 | ); | |
| 1287 | const ids = [...html.matchAll(/<h2 id="([^"]+)"/g)].map((match) => match[1]); | |
| 1288 | expect(ids).toEqual([ | |
| 1289 | "user-content-setup", | |
| 1290 | "user-content-setup-1", | |
| 1291 | "user-content-setup-1-1", | |
| 1292 | "user-content-install-1", | |
| 1293 | "user-content-install", | |
| 1294 | "user-content-install-2", | |
| 1295 | ]); | |
| 1296 | expect(new Set(ids).size).toBe(ids.length); | |
| 1297 | }); | |
| 1298 | }); | |
| 1299 | ||
| 1300 | describe("ChatMarkdown in-page links", () => { | |
| 1301 | it.each([true, false])( | |
| 1302 | "scrolls a table-of-contents link to its heading without touching the URL (parseRawHtml=%s)", | |
| 1303 | async (parseRawHtml) => { | |
| 1304 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 1305 | const { createRoot } = await import("react-dom/client"); | |
| 1306 | const container = document.createElement("div"); | |
| 1307 | document.body.append(container); | |
| 1308 | const root = createRoot(container); | |
| 1309 | window.history.replaceState(null, "", "/#/env/thread"); | |
| 1310 | const scrollIntoView = vi.fn(); | |
| 1311 | HTMLElement.prototype.scrollIntoView = scrollIntoView; | |
| 1312 | try { | |
| 1313 | await act(async () => { | |
| 1314 | root.render( | |
| 1315 | <ChatMarkdown | |
| 1316 | cwd="/tmp/project" | |
| 1317 | parseRawHtml={parseRawHtml} | |
| 1318 | text={ | |
| 1319 | "- [Operating model](#1-operating-model)\n- [Missing](#nowhere)\n\n## 1. Operating model\n\n## 1. Operating model" | |
| 1320 | } | |
| 1321 | />, | |
| 1322 | ); | |
| 1323 | }); | |
| 1324 | const headings = [...container.querySelectorAll("h2")]; | |
| 1325 | expect(headings.map((heading) => heading.id)).toEqual([ | |
| 1326 | "user-content-1-operating-model", | |
| 1327 | "user-content-1-operating-model-1", | |
| 1328 | ]); | |
| 1329 | ||
| 1330 | const [tocLink, missingLink] = [...container.querySelectorAll("a")]; | |
| 1331 | const click = () => new MouseEvent("click", { bubbles: true, cancelable: true }); | |
| 1332 | const tocClick = click(); | |
| 1333 | tocLink!.dispatchEvent(tocClick); | |
| 1334 | expect(tocClick.defaultPrevented).toBe(true); | |
| 1335 | expect(scrollIntoView.mock.contexts).toEqual([headings[0]]); | |
| 1336 | ||
| 1337 | const missingClick = click(); | |
| 1338 | missingLink!.dispatchEvent(missingClick); | |
| 1339 | expect(missingClick.defaultPrevented).toBe(true); | |
| 1340 | expect(scrollIntoView).toHaveBeenCalledTimes(1); | |
| 1341 | expect(window.location.hash).toBe("#/env/thread"); | |
| 1342 | } finally { | |
| 1343 | await act(async () => root.unmount()); | |
| 1344 | container.remove(); | |
| 1345 | } | |
| 1346 | }, | |
| 1347 | ); | |
| 1348 | }); |
apps/web/src/components/ChatMarkdown.tsx+73-8| ... | ... | @@ -1807,16 +1807,77 @@ function handleMarkdownFragmentClick(event: ReactMouseEvent<HTMLAnchorElement>, |
| 1807 | 1807 | return; |
| 1808 | 1808 | } |
| 1809 | 1809 | |
| 1810 | const target = findMarkdownFragmentTarget(event.currentTarget, href); | |
| 1811 | if (!target) return; | |
| 1812 | ||
| 1810 | // Never let the browser follow the fragment or write it to the URL: desktop keeps | |
| 1811 | // its route in the hash, so replacing the hash navigates away from the thread. | |
| 1813 | 1812 | event.preventDefault(); |
| 1814 | const nextUrl = new URL(window.location.href); | |
| 1815 | nextUrl.hash = href.slice(1); | |
| 1816 | window.history.pushState(window.history.state, "", nextUrl); | |
| 1817 | target.scrollIntoView({ block: "nearest" }); | |
| 1813 | findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" }); | |
| 1814 | } | |
| 1815 | ||
| 1816 | type HeadingHastNode = { | |
| 1817 | type?: string; | |
| 1818 | tagName?: string; | |
| 1819 | properties?: Record<string, unknown>; | |
| 1820 | children?: HeadingHastNode[]; | |
| 1821 | }; | |
| 1822 | ||
| 1823 | /** GitHub's heading anchor slug, so `[Setup](#setup)` table-of-contents links find their heading. */ | |
| 1824 | function githubHeadingSlug(text: string): string { | |
| 1825 | return text | |
| 1826 | .trim() | |
| 1827 | .toLowerCase() | |
| 1828 | .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "") | |
| 1829 | .replace(/ /g, "-"); | |
| 1830 | } | |
| 1831 | ||
| 1832 | /** | |
| 1833 | * Gives headings without an authored id GitHub's slug id, deduplicated per document. Like the | |
| 1834 | * sanitizer's ids, they carry the `user-content-` prefix so they cannot clobber app element ids; | |
| 1835 | * fragment lookup strips it. | |
| 1836 | */ | |
| 1837 | function rehypeHeadingIds() { | |
| 1838 | return (tree: HeadingHastNode) => { | |
| 1839 | // Every id already in the document, authored or assigned, so a suffix never | |
| 1840 | // lands on one that exists: `Setup`, `Setup`, `Setup-1` get three distinct ids. | |
| 1841 | const taken = new Set<string>(); | |
| 1842 | const collect = (node: HeadingHastNode) => { | |
| 1843 | const id = node.properties?.id; | |
| 1844 | if (typeof id === "string") taken.add(id); | |
| 1845 | node.children?.forEach(collect); | |
| 1846 | }; | |
| 1847 | collect(tree); | |
| 1848 | const nextSuffix = new Map<string, number>(); | |
| 1849 | const visit = (node: HeadingHastNode) => { | |
| 1850 | if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { | |
| 1851 | const slug = githubHeadingSlug(hastPlainTextDeep(node)); | |
| 1852 | if (node.properties?.id === undefined && slug) { | |
| 1853 | let count = nextSuffix.get(slug) ?? 0; | |
| 1854 | let id = `${SANITIZED_FRAGMENT_PREFIX}${slug}`; | |
| 1855 | while (taken.has(id)) { | |
| 1856 | count += 1; | |
| 1857 | id = `${SANITIZED_FRAGMENT_PREFIX}${slug}-${count}`; | |
| 1858 | } | |
| 1859 | nextSuffix.set(slug, count); | |
| 1860 | taken.add(id); | |
| 1861 | node.properties = { ...node.properties, id }; | |
| 1862 | } | |
| 1863 | return; | |
| 1864 | } | |
| 1865 | node.children?.forEach(visit); | |
| 1866 | }; | |
| 1867 | visit(tree); | |
| 1868 | }; | |
| 1818 | 1869 | } |
| 1819 | 1870 | |
| 1871 | // Heading ids are added after sanitizing, which would prefix them a second time. | |
| 1872 | const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ | |
| 1873 | ...CHAT_MARKDOWN_REHYPE_PLUGINS, | |
| 1874 | rehypeHeadingIds, | |
| 1875 | ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; | |
| 1876 | ||
| 1877 | const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< | |
| 1878 | ReactMarkdownOptions["rehypePlugins"] | |
| 1879 | >; | |
| 1880 | ||
| 1820 | 1881 | function MarkdownExternalLinkContent({ |
| 1821 | 1882 | host, |
| 1822 | 1883 | plainText, |
| ... | ... | @@ -3363,7 +3424,11 @@ function ChatMarkdown({ |
| 3363 | 3424 | <ChatMarkdownRendererContext value={componentState}> |
| 3364 | 3425 | <ReactMarkdown |
| 3365 | 3426 | remarkPlugins={remarkPlugins} |
| 3366 | rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined} | |
| 3427 | rehypePlugins={ | |
| 3428 | parseRawHtml | |
| 3429 | ? CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS | |
| 3430 | : CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS | |
| 3431 | } | |
| 3367 | 3432 | skipHtml={false} |
| 3368 | 3433 | components={CHAT_MARKDOWN_COMPONENTS} |
| 3369 | 3434 | urlTransform={markdownUrlTransform} |
apps/web/src/components/files/BrowserDocumentFrame.tsx+1-1| ... | ... | @@ -55,7 +55,7 @@ export function BrowserDocumentFrame(props: { |
| 55 | 55 | src={props.src} |
| 56 | 56 | title={props.title} |
| 57 | 57 | className={className} |
| 58 | sandbox="allow-scripts allow-forms allow-popups" | |
| 58 | sandbox="allow-scripts allow-forms allow-popups allow-downloads" | |
| 59 | 59 | /> |
| 60 | 60 | ); |
| 61 | 61 | } |
apps/web/src/markdown-links.test.ts+29| ... | ... | @@ -91,6 +91,35 @@ describe("relative links inside a rendered host file", () => { |
| 91 | 91 | workspaceRelativePath: "docs/src/main.ts", |
| 92 | 92 | }); |
| 93 | 93 | }); |
| 94 | ||
| 95 | it("resolve multi-segment inline code from the workspace root in a workspace file", () => { | |
| 96 | expect( | |
| 97 | resolveInlineCodeFileLinkMeta("docs/ai/design.md", "/repo", "/repo/docs/ai"), | |
| 98 | ).toMatchObject({ filePath: "/repo/docs/ai/design.md" }); | |
| 99 | expect( | |
| 100 | resolveInlineCodeFileLinkMeta("src/index.ts:4", "/repo", "/repo/packages/a"), | |
| 101 | ).toMatchObject({ filePath: "/repo/src/index.ts", line: 4 }); | |
| 102 | }); | |
| 103 | ||
| 104 | it("keep sibling and explicitly relative inline code beside the file", () => { | |
| 105 | expect(resolveInlineCodeFileLinkMeta("design.md:12", "/repo", "/repo/docs/ai")).toMatchObject({ | |
| 106 | filePath: "/repo/docs/ai/design.md", | |
| 107 | line: 12, | |
| 108 | }); | |
| 109 | expect( | |
| 110 | resolveInlineCodeFileLinkMeta("./src/index.ts", "/repo", "/repo/packages/a"), | |
| 111 | ).toMatchObject({ filePath: "/repo/packages/a/./src/index.ts" }); | |
| 112 | expect(resolveInlineCodeFileLinkMeta("../b/notes.md", "/repo", "/repo/docs/a")).toMatchObject({ | |
| 113 | filePath: "/repo/docs/a/../b/notes.md", | |
| 114 | }); | |
| 115 | }); | |
| 116 | ||
| 117 | it("keep multi-segment inline code beside a file outside the workspace", () => { | |
| 118 | expect(resolveInlineCodeFileLinkMeta("src/main.ts", "/repo", "/tmp/report")).toMatchObject({ | |
| 119 | filePath: "/tmp/report/src/main.ts", | |
| 120 | workspaceRelativePath: null, | |
| 121 | }); | |
| 122 | }); | |
| 94 | 123 | }); |
| 95 | 124 | |
| 96 | 125 | describe("resolveInlineCodeFileLinkMeta", () => { |
apps/web/src/markdown-links.ts+22-1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { fileBasename, workspaceRelativeFilePath } from "@t3tools/shared/path"; |
| 2 | 2 | import { |
| 3 | 3 | inlineCodeFilePathCandidate, |
| 4 | isRelativeFilePath, | |
| 4 | 5 | normalizeMarkdownLinkDestination, |
| 5 | 6 | resolveMarkdownFileLinkTarget, |
| 6 | 7 | } from "@t3tools/shared/markdownLinks"; |
| ... | ... | @@ -50,7 +51,27 @@ export function resolveInlineCodeFileLinkMeta( |
| 50 | 51 | const candidate = inlineCodeFilePathCandidate(codeText); |
| 51 | 52 | if (candidate === null) return null; |
| 52 | 53 | |
| 53 | return resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); | |
| 54 | return resolveMarkdownFileLinkMeta( | |
| 55 | candidate, | |
| 56 | cwd, | |
| 57 | inlineCodePathNamesFromWorkspaceRoot(candidate, cwd, baseDir) ? cwd : baseDir, | |
| 58 | ); | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * Prose in a workspace file names other files from the repo root (`docs/ai/design.md`), | |
| 63 | * unlike an explicit link. Single-segment names (`design.md:12`) and `./`, `../` | |
| 64 | * paths still read as siblings, and files outside the workspace keep their own base. | |
| 65 | */ | |
| 66 | function inlineCodePathNamesFromWorkspaceRoot( | |
| 67 | candidate: string, | |
| 68 | cwd: string | undefined, | |
| 69 | baseDir: string | undefined, | |
| 70 | ): boolean { | |
| 71 | if (!cwd || !baseDir || !isRelativeFilePath(candidate)) return false; | |
| 72 | if (/^(?:~|\.{1,2})\//.test(candidate)) return false; | |
| 73 | if (!splitFilePathPosition(candidate).path.includes("/")) return false; | |
| 74 | return workspaceRelativeFilePath(baseDir, cwd) !== null; | |
| 54 | 75 | } |
| 55 | 76 | |
| 56 | 77 | export function resolveMarkdownFileLinkMeta( |
packages/shared/src/favicon.test.ts+7-2| ... | ... | @@ -23,13 +23,18 @@ describe("faviconUrlForOrigin", () => { |
| 23 | 23 | "http://service.test", |
| 24 | 24 | "http://private.onion", |
| 25 | 25 | "http://127.1..", |
| 26 | "https://grafana.corp", | |
| 27 | "https://build.lan", | |
| 28 | "https://wiki.intranet", | |
| 29 | "https://grafana.internal.acme-corp.com", | |
| 30 | "https://jira.corp.acme-corp.com:8443", | |
| 26 | 31 | ])("does not disclose %s to the favicon provider", (origin) => { |
| 27 | 32 | expect(faviconUrlForOrigin(origin)).toBeNull(); |
| 28 | 33 | }); |
| 29 | 34 | |
| 30 | it("keeps the public origin, port and requested size", () => { | |
| 35 | it("sends only the public hostname and requested size, never the port", () => { | |
| 31 | 36 | expect(faviconUrlForOrigin("https://github.com:8443/pingdotgg/t3code?private=query", 64)).toBe( |
| 32 | "https://www.google.com/s2/favicons?domain=github.com%3A8443&sz=64", | |
| 37 | "https://www.google.com/s2/favicons?domain=github.com&sz=64", | |
| 33 | 38 | ); |
| 34 | 39 | }); |
| 35 | 40 |
packages/shared/src/favicon.ts+7-3| ... | ... | @@ -82,15 +82,19 @@ export function toolActivityFaviconUrl( |
| 82 | 82 | ); |
| 83 | 83 | } |
| 84 | 84 | |
| 85 | /** Return a public favicon URL without disclosing private or reserved hosts. */ | |
| 85 | /** | |
| 86 | * Return a public favicon URL that discloses only a public hostname, never the | |
| 87 | * port, path, or a private or internal-looking host. Callers show a generic | |
| 88 | * icon when this returns null. | |
| 89 | */ | |
| 86 | 90 | export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null { |
| 87 | 91 | if (!rawUrl) return null; |
| 88 | 92 | try { |
| 89 | 93 | const url = new URL(rawUrl); |
| 90 | if (!url.host) return null; | |
| 94 | if (!url.hostname) return null; | |
| 91 | 95 | if (url.protocol !== "http:" && url.protocol !== "https:") return null; |
| 92 | 96 | if (!isPublicFaviconHost(url.hostname)) return null; |
| 93 | return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.host)}&sz=${size}`; | |
| 97 | return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.hostname)}&sz=${size}`; | |
| 94 | 98 | } catch { |
| 95 | 99 | return null; |
| 96 | 100 | } |
packages/shared/src/hostClassification.ts+21-1| ... | ... | @@ -119,6 +119,23 @@ export const isPrivateNetworkHost = (host: string): boolean => { |
| 119 | 119 | ); |
| 120 | 120 | }; |
| 121 | 121 | |
| 122 | // Reserved and special-use names, plus the private TLDs RFC 6762 Appendix G | |
| 123 | // records as common on internal networks. | |
| 124 | const PRIVATE_FAVICON_TLDS = [ | |
| 125 | ".alt", | |
| 126 | ".corp", | |
| 127 | ".example", | |
| 128 | ".home", | |
| 129 | ".internal", | |
| 130 | ".intranet", | |
| 131 | ".invalid", | |
| 132 | ".lan", | |
| 133 | ".onion", | |
| 134 | ".private", | |
| 135 | ".test", | |
| 136 | ]; | |
| 137 | const INTERNAL_HOST_LABELS: ReadonlySet<string> = new Set(["corp", "internal", "intranet"]); | |
| 138 | ||
| 122 | 139 | /** Whether a hostname is eligible to be disclosed to a public favicon provider. */ |
| 123 | 140 | export const isPublicFaviconHost = (host: string): boolean => { |
| 124 | 141 | // A single trailing dot is a valid absolute DNS name. Repeated trailing |
| ... | ... | @@ -127,12 +144,15 @@ export const isPublicFaviconHost = (host: string): boolean => { |
| 127 | 144 | const normalized = normalizeHostname(host); |
| 128 | 145 | if (isPrivateNetworkHost(normalized)) return false; |
| 129 | 146 | if ( |
| 130 | [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some( | |
| 147 | PRIVATE_FAVICON_TLDS.some( | |
| 131 | 148 | (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix), |
| 132 | 149 | ) |
| 133 | 150 | ) { |
| 134 | 151 | return false; |
| 135 | 152 | } |
| 153 | // Company networks often nest internal services under a public domain | |
| 154 | // (`grafana.internal.acme.com`); never send those names to a third party. | |
| 155 | if (normalized.split(".").some((label) => INTERNAL_HOST_LABELS.has(label))) return false; | |
| 136 | 156 | const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); |
| 137 | 157 | if (ipv4) return !isSpecialPurposeIpv4Address(ipv4); |
| 138 | 158 | if (!normalized.includes(":")) return true; |
packages/shared/src/markdownLinks.ts+1-1| ... | ... | @@ -252,7 +252,7 @@ export function isMarkdownFileLinkLabel(label: string, href: string): boolean { |
| 252 | 252 | return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`); |
| 253 | 253 | } |
| 254 | 254 | |
| 255 | function isRelativeFilePath(path: string): boolean { | |
| 255 | export function isRelativeFilePath(path: string): boolean { | |
| 256 | 256 | return ( |
| 257 | 257 | RELATIVE_PATH_PREFIX_PATTERN.test(path) || |
| 258 | 258 | (!path.startsWith("/") && !isWindowsAbsolutePath(path)) |