-
Notifications
You must be signed in to change notification settings - Fork 5
Add external link indicators to documentation #645
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
8764119
Initial plan
Copilot 7f6fb70
Add external link arrow component
Copilot f40afac
Add CSS-based external link arrows for all article links
Copilot 2932599
Address code review feedback: improve SSR compatibility and CSS maint…
Copilot ad7ee47
Add external link indicators to documentation
Copilot a5cd632
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes d72a72f
fix: Prevent duplicate external link arrows by adding a `data-externa…
yamcodes 3545288
test: verify ExternalLink arrow icon is hidden from screen readers
yamcodes a4b00b1
[autofix.ci] apply automated fixes
autofix-ci[bot] 4d25b7d
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes 4b6f27b
feat: Refactor external link component to always include `data-extern…
yamcodes 33b215b
style: Adjust external link arrow icon spacing and update CSS SVG enc…
yamcodes e55f481
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes 3d87105
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes 08ca4ae
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes ffe3f24
feat: enhance external link arrow icon styling by using CSS masks for…
yamcodes efbff92
feat: add external link indicator to cards and refactor URL detection…
yamcodes 668bf3e
style: Adjust external link icon spacing in card and reformat article…
yamcodes fb4ab1a
style: reduce gap between card title and external link icon
yamcodes 96e1c33
feat: Introduce `data-no-underline` and `data-no-arrow` attributes to…
yamcodes baad019
refactor: improve readability of complex CSS selectors by adding line…
yamcodes 0dcc4f8
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes 8767885
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes 7919ac1
feat: Conditionally apply `data-external-link` attribute based on `is…
yamcodes File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import { render, screen } from "@testing-library/react"; | ||
| import type { ComponentPropsWithoutRef } from "react"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { ExternalLink } from "./external-link"; | ||
|
|
||
| // Mock the fumadocs-core/link module | ||
| vi.mock("fumadocs-core/link", () => ({ | ||
| default: ({ href, children, ...props }: ComponentPropsWithoutRef<"a">) => ( | ||
| <a href={href} {...props}> | ||
| {children} | ||
| </a> | ||
| ), | ||
| })); | ||
|
|
||
| describe("ExternalLink", () => { | ||
| describe("external link detection", () => { | ||
| it("should render arrow icon for external HTTP links", () => { | ||
| render( | ||
| <ExternalLink href="https://example.com">External Link</ExternalLink>, | ||
| ); | ||
| const link = screen.getByText("External Link"); | ||
| expect(link).toBeInTheDocument(); | ||
| // Check for arrow icon by checking SVG is present | ||
| const svg = link.closest("a")?.querySelector("svg"); | ||
| expect(svg).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("should render arrow icon for external HTTPS links", () => { | ||
| render( | ||
| <ExternalLink href="https://arktype.io">ArkType Docs</ExternalLink>, | ||
| ); | ||
| const link = screen.getByText("ArkType Docs"); | ||
| const svg = link.closest("a")?.querySelector("svg"); | ||
| expect(svg).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("should NOT render arrow icon for internal relative links", () => { | ||
| render( | ||
| <ExternalLink href="/docs/quickstart">Internal Link</ExternalLink>, | ||
| ); | ||
| const link = screen.getByText("Internal Link"); | ||
| const svg = link.closest("a")?.querySelector("svg"); | ||
| expect(svg).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("should NOT render arrow icon for hash links", () => { | ||
| render(<ExternalLink href="#section">Hash Link</ExternalLink>); | ||
| const link = screen.getByText("Hash Link"); | ||
| const svg = link.closest("a")?.querySelector("svg"); | ||
| expect(svg).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("should NOT render arrow icon when href is undefined", () => { | ||
| render(<ExternalLink>No Href Link</ExternalLink>); | ||
| const link = screen.getByText("No Href Link"); | ||
| const svg = link.closest("a")?.querySelector("svg"); | ||
| expect(svg).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("should NOT render arrow icon for arkenv.js.org links (same domain)", () => { | ||
| render( | ||
| <ExternalLink href="https://arkenv.js.org/docs/arkenv"> | ||
| Same Domain Link | ||
| </ExternalLink>, | ||
| ); | ||
| const link = screen.getByText("Same Domain Link"); | ||
| const svg = link.closest("a")?.querySelector("svg"); | ||
| expect(svg).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("should NOT render arrow icon for localhost links", () => { | ||
| render( | ||
| <ExternalLink href="http://localhost:3000/docs"> | ||
| Localhost Link | ||
| </ExternalLink>, | ||
| ); | ||
| const link = screen.getByText("Localhost Link"); | ||
| const svg = link.closest("a")?.querySelector("svg"); | ||
| expect(svg).not.toBeInTheDocument(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("link rendering", () => { | ||
| it("should render using fumadocs Link component", () => { | ||
| render( | ||
| <ExternalLink href="https://example.com">External Link</ExternalLink>, | ||
| ); | ||
| const link = screen.getByText("External Link").closest("a"); | ||
| expect(link).toHaveAttribute("href", "https://example.com"); | ||
| }); | ||
|
|
||
| it("should preserve other HTML attributes", () => { | ||
| render( | ||
| <ExternalLink href="/docs" data-testid="test-link" aria-label="Test"> | ||
| Link | ||
| </ExternalLink>, | ||
| ); | ||
| const link = screen.getByText("Link").closest("a"); | ||
| expect(link).toHaveAttribute("data-testid", "test-link"); | ||
| expect(link).toHaveAttribute("aria-label", "Test"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("accessibility", () => { | ||
| it("should render arrow icon that is hidden from screen readers", () => { | ||
| render( | ||
| <ExternalLink href="https://example.com">External Link</ExternalLink>, | ||
| ); | ||
| const link = screen.getByText("External Link").closest("a"); | ||
| const svg = link?.querySelector("svg"); | ||
| expect(svg).toHaveAttribute("aria-hidden", "true"); | ||
| }); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| "use client"; | ||
|
|
||
| import FumadocsLink from "fumadocs-core/link"; | ||
| import { ArrowUpRight } from "lucide-react"; | ||
| import type { ComponentProps, FC } from "react"; | ||
| import { isExternalUrl } from "~/lib/utils/url"; | ||
|
|
||
| export interface ExternalLinkProps extends ComponentProps<typeof FumadocsLink> { | ||
| href?: string; | ||
| } | ||
|
|
||
| /** | ||
| * ExternalLink component that automatically adds an arrow icon to external links. | ||
| * Wraps fumadocs Link component and adds arrow icon for external URLs. | ||
| */ | ||
| export const ExternalLink: FC<ExternalLinkProps> = ({ | ||
| href, | ||
| children, | ||
| ...props | ||
| }) => { | ||
| const isExternal = isExternalUrl(href); | ||
|
|
||
| return ( | ||
| <FumadocsLink | ||
| href={href} | ||
| data-external-link={isExternal || undefined} | ||
| {...props} | ||
| > | ||
| {children} | ||
| {isExternal && ( | ||
| <ArrowUpRight | ||
| className="inline align-middle h-[0.9em] w-[0.9em] opacity-70 ml-0.5" | ||
| stroke="currentColor" | ||
| aria-hidden="true" | ||
| /> | ||
| )} | ||
| </FumadocsLink> | ||
| ); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| /** | ||
| * List of domains that should be treated as internal (same site) | ||
| */ | ||
| export const INTERNAL_DOMAINS = [ | ||
| "arkenv.js.org", | ||
| "www.arkenv.js.org", | ||
| "localhost", | ||
| "127.0.0.1", | ||
| ]; | ||
|
|
||
| /** | ||
| * Checks if a URL is external (not same domain or not a relative path) | ||
| * This utility is safe to call from both Client and Server components. | ||
| */ | ||
| export function isExternalUrl(href: string | undefined): boolean { | ||
| if (!href) return false; | ||
|
|
||
| // Internal relative paths | ||
| if (href.startsWith("/") || href.startsWith("#")) return false; | ||
|
|
||
| // Check if it's an absolute URL | ||
| try { | ||
| // Use a dummy origin for SSR compatibility | ||
| const base = | ||
| typeof window !== "undefined" | ||
| ? window.location.origin | ||
| : "https://arkenv.js.org"; | ||
| const url = new URL(href, base); | ||
|
|
||
| // Check against internal domains list | ||
| const hostname = url.hostname.toLowerCase(); | ||
| if ( | ||
| INTERNAL_DOMAINS.some( | ||
| // hostname matches exactly or is a subdomain (leading dot prevents matches like "evilarkenv.js.org") | ||
| (domain) => hostname === domain || hostname.endsWith(`.${domain}`), | ||
| ) | ||
| ) { | ||
| return false; | ||
| } | ||
|
|
||
| // External if different origin (only check when window is available) | ||
| if (typeof window !== "undefined") { | ||
| return url.origin !== window.location.origin; | ||
| } | ||
|
|
||
| // During SSR, check if it's an absolute URL with http/https | ||
| return url.protocol === "http:" || url.protocol === "https:"; | ||
| } catch { | ||
| // If URL parsing fails, treat as internal | ||
| return false; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.