Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8764119
Initial plan
Copilot Dec 27, 2025
7f6fb70
Add external link arrow component
Copilot Dec 27, 2025
f40afac
Add CSS-based external link arrows for all article links
Copilot Dec 27, 2025
2932599
Address code review feedback: improve SSR compatibility and CSS maint…
Copilot Dec 27, 2025
ad7ee47
Add external link indicators to documentation
Copilot Dec 27, 2025
a5cd632
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes Dec 27, 2025
d72a72f
fix: Prevent duplicate external link arrows by adding a `data-externa…
yamcodes Dec 27, 2025
3545288
test: verify ExternalLink arrow icon is hidden from screen readers
yamcodes Dec 27, 2025
a4b00b1
[autofix.ci] apply automated fixes
autofix-ci[bot] Dec 27, 2025
4d25b7d
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes Dec 27, 2025
4b6f27b
feat: Refactor external link component to always include `data-extern…
yamcodes Dec 27, 2025
33b215b
style: Adjust external link arrow icon spacing and update CSS SVG enc…
yamcodes Dec 27, 2025
e55f481
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes Dec 27, 2025
3d87105
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes Dec 27, 2025
08ca4ae
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes Dec 27, 2025
ffe3f24
feat: enhance external link arrow icon styling by using CSS masks for…
yamcodes Dec 27, 2025
efbff92
feat: add external link indicator to cards and refactor URL detection…
yamcodes Dec 27, 2025
668bf3e
style: Adjust external link icon spacing in card and reformat article…
yamcodes Dec 27, 2025
fb4ab1a
style: reduce gap between card title and external link icon
yamcodes Dec 27, 2025
96e1c33
feat: Introduce `data-no-underline` and `data-no-arrow` attributes to…
yamcodes Dec 27, 2025
baad019
refactor: improve readability of complex CSS selectors by adding line…
yamcodes Dec 27, 2025
0dcc4f8
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes Dec 27, 2025
8767885
Merge branch 'main' into copilot/add-external-link-arrow
yamcodes Dec 27, 2025
7919ac1
feat: Conditionally apply `data-external-link` attribute based on `is…
yamcodes Dec 27, 2025
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
41 changes: 41 additions & 0 deletions apps/www/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,47 @@
@plugin "tailwindcss-animate";
@source "../node_modules/fumadocs-ui/dist/**/*.js";

/* External link arrow icon as CSS custom property */
@theme {
--external-link-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 7h10v10'/%3E%3Cpath d='M7 17 17 7'/%3E%3C/svg%3E");
}

/* Link underlines in documentation */
article
a:not(:has(img)):not(.fd-card):not([data-card]):not([class*="fd-"]):not(
[data-no-underline]
) {
text-decoration: none;
border-bottom: 1.5px solid currentColor;
padding-bottom: 1px;
transition: border-bottom-width 0.1s ease;
}

article
a:not(:has(img)):not(.fd-card):not([data-card]):not([class*="fd-"]):not(
[data-no-underline]
):hover {
border-bottom-width: 2.5px;
}

article
a[href^="http"]:not([href*="arkenv.js.org"]):not([href*="localhost"]):not(
[data-external-link]
):not(.fd-card):not([data-card]):not([class*="fd-"]):not(
[data-no-arrow]
)::after {
content: "";
display: inline-block;
width: 0.9em;
height: 0.9em;
margin-left: 0.125rem;
background-color: currentColor;
-webkit-mask: var(--external-link-arrow) no-repeat center / contain;
mask: var(--external-link-arrow) no-repeat center / contain;
opacity: 0.7;
vertical-align: middle;
}

button[data-search-full] {
--color-fd-secondary: hsl(0, 0%, 100%);
--color-fd-accent: hsl(0, 0%, 100%);
Expand Down
2 changes: 2 additions & 0 deletions apps/www/components/page/edit-on-github.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export const EditOnGithub = ({ path }: EditOnGithubProps) => {
)}
target="_blank"
rel="noopener noreferrer"
data-no-underline
data-no-arrow
>
<SquarePen
aria-hidden="true"
Expand Down
25 changes: 23 additions & 2 deletions apps/www/components/ui/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,28 @@ import {
Card as CardComponent,
type CardProps,
} from "fumadocs-ui/components/card";
import { ArrowUpRight } from "lucide-react";
import { isExternalUrl } from "~/lib/utils/url";

export function Card(props: CardProps) {
return <CardComponent {...props} className="[&>p:last-child]:mb-0" />;
export function Card({ title, ...props }: CardProps) {
const isExternal = isExternalUrl(props.href);

const augmentedTitle = isExternal ? (
<span className="flex items-center gap-0.5">
{title}
<ArrowUpRight className="h-3.5 w-3.5 opacity-40 group-hover:opacity-100 transition-opacity" />
</span>
) : (
title
);

return (
<CardComponent
{...props}
title={augmentedTitle}
data-card
data-external-link={isExternal || undefined}
className="[&>p:last-child]:mb-0"
/>
);
}
114 changes: 114 additions & 0 deletions apps/www/components/ui/external-link.test.tsx
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");
});
});
});
39 changes: 39 additions & 0 deletions apps/www/components/ui/external-link.tsx
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>
);
};
4 changes: 2 additions & 2 deletions apps/www/content/docs/arkenv/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ The ArkEnv icon tells the story of what we're building. It weaves together three
<Cards>
<Card title="Quickstart" href="/docs/quickstart" description="Add ArkEnv to an existing project" />
<Card title="Start with an example" href="/docs/examples" description="Explore our collection of examples to start a new project" />
<Card icon={<GitHub aria-hidden="true" />} title="ArkEnv on GitHub" href="https://github.com/yamcodes/arkenv" description="View the source code and contribute to the project" />
<Card icon={<Globe aria-hidden="true" />} title="ArkType Documentation" href="https://arktype.io/docs" description="Learn more about the underlying type system" className="bg-blue-900" />
<Card title="ArkEnv on GitHub" href="https://github.com/yamcodes/arkenv" description="View the source code and contribute to the project" />
<Card title="ArkType Documentation" href="https://arktype.io/docs" description="Learn more about the underlying type system" className="bg-blue-900" />
</Cards>
2 changes: 1 addition & 1 deletion apps/www/content/docs/vite-plugin/arkenv-in-viteconfig.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,5 @@ Note how the schema is defined once but used in two different places:
2. Public `VITE_*` variables that should be available in your client code (like `VITE_API_URL`) are validated by the `@arkenv/vite-plugin`. The plugin automatically filters the schema to only expose variables matching the Vite prefix (defaults to `VITE_`), preventing server-only variables from leaking into the client bundle.

<Cards>
<Card icon={<Globe aria-hidden="true" />} title="Using Environment Variables in Config (Vite docs)" href="https://vite.dev/config/#using-environment-variables-in-config" />
<Card title="Using Environment Variables in Config (Vite docs)" href="https://vite.dev/config/#using-environment-variables-in-config" />
</Cards>
52 changes: 52 additions & 0 deletions apps/www/lib/utils/url.ts
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;
}
}
4 changes: 3 additions & 1 deletion apps/www/mdx-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { MDXComponents } from "mdx/types";
import type { ComponentPropsWithoutRef } from "react";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { ExternalLink } from "~/components/ui/external-link";
import { Heading } from "~/components/ui/heading";

const createHeadingComponent =
Expand All @@ -29,6 +30,7 @@ const customComponents = {
Steps,
Accordion,
Accordions,
a: ExternalLink,
h1: createHeadingComponent("h1"),
h2: createHeadingComponent("h2"),
h3: createHeadingComponent("h3"),
Expand All @@ -47,8 +49,8 @@ const customComponents = {
export function getMDXComponents(components: MDXComponents): MDXComponents {
return {
...defaultComponents,
...customComponents,
...twoslashComponents,
...customComponents,
...components,
};
}
16 changes: 8 additions & 8 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ This directory contains a collection of example projects that demonstrate variou

## Examples

| Name | Description |
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| [`basic`](https://github.com/yamcodes/arkenv/tree/main/examples/basic) | Minimal example of _using ArkEnv in a [Node.js](https://nodejs.org/) app_ for learning the fundamentals. |
| [`with-standard-schema`](https://github.com/yamcodes/arkenv/tree/main/examples/with-standard-schema) | Example of _mixing ArkType with [Standard Schema](https://standardschema.dev/) validators like [Zod](https://zod.dev/)_. |
| [`with-bun`](https://github.com/yamcodes/arkenv/tree/main/examples/with-bun) | Minimal example of _using ArkEnv in a [Bun](https://bun.sh/) app_. |
| [`with-bun-react`](https://github.com/yamcodes/arkenv/tree/main/examples/with-bun-react) | Minimal example of _using ArkEnv in a [Bun+React](https://bun.com/docs/guides/ecosystem/react) full-stack app_. |
| [`with-vite-react`](https://github.com/yamcodes/arkenv/tree/main/examples/with-vite-react) | Minimal example of _using ArkEnv in a [Vite](https://vite.dev/)+[React](https://react.dev/) app_. |
| Name | Description |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| [basic](https://github.com/yamcodes/arkenv/tree/main/examples/basic) | Minimal example of _using ArkEnv in a [Node.js](https://nodejs.org/) app_ for learning the fundamentals. |
| [with-standard-schema](https://github.com/yamcodes/arkenv/tree/main/examples/with-standard-schema) | Example of _mixing ArkType with [Standard Schema](https://standardschema.dev/) validators like [Zod](https://zod.dev/)_. |
| [with-bun](https://github.com/yamcodes/arkenv/tree/main/examples/with-bun) | Minimal example of _using ArkEnv in a [Bun](https://bun.sh/) app_. |
| [with-bun-react](https://github.com/yamcodes/arkenv/tree/main/examples/with-bun-react) | Minimal example of _using ArkEnv in a [Bun + React](https://bun.com/docs/guides/ecosystem/react) full-stack app_. |
| [with-vite-react](https://github.com/yamcodes/arkenv/tree/main/examples/with-vite-react) | Minimal example of _using ArkEnv in a [Vite](https://vite.dev/) + [React](https://react.dev/) app_. |

> These examples are written in TypeScript, [the recommended way to work with ArkEnv](https://github.com/yamcodes/arkenv/blob/main/packages/arkenv/README.md#typescript-setup). That being said, ArkEnv works with plain JavaScript, with tradeoffs. See the [`basic-js`] (https://github.com/yamcodes/arkenv/tree/main/examples/basic-js) example for details.
> These examples are written in TypeScript, [the recommended way to work with ArkEnv](https://github.com/yamcodes/arkenv/blob/main/packages/arkenv/README.md#typescript-setup). That being said, ArkEnv works with plain JavaScript, with tradeoffs. See the [basic-js`](https://github.com/yamcodes/arkenv/tree/main/examples/basic-js) example for details.

## Contributing an example

Expand Down
Loading