Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/tidy-routers-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@dualmark/remix": minor
"@dualmark/core": minor
---

Add React Router v7 Framework Mode adapter with generated markdown resource routes and entry.server negotiation.

Add shared bot-aware content negotiation that respects explicit Accept preferences.
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,42 @@ Full example with `vite dev` → 125/125 conformance score:

[Full SvelteKit example →](./examples/sveltekit-blog)

### React Router v7 / Remix (60 seconds)

```bash
bun add @dualmark/remix
```

```ts
// vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import dualmark from "@dualmark/remix";
import dualmarkConfig from "./app/dualmark.config";

export default defineConfig({
plugins: [dualmark(dualmarkConfig), reactRouter()],
});
```

```ts
// app/routes.ts
import { index, route, type RouteConfig } from "@react-router/dev/routes";
import { dualmarkRoutes } from "@dualmark/remix/routes";
import dualmarkConfig from "./dualmark.config";

export default [
index("routes/home.tsx"),
route("posts", "routes/posts.tsx"),
route("posts/:slug", "routes/post.tsx"),
...dualmarkRoutes(dualmarkConfig),
] satisfies RouteConfig;
```

Full example with `react-router dev` → 125/125 conformance score:

[Full React Router example →](./examples/remix-blog)

### Cloudflare Workers (60 seconds)

Wrap your existing Worker. AI bots get markdown at the edge — single-digit-ms first-byte from 300+ cities.
Expand Down Expand Up @@ -308,6 +344,7 @@ Three conformance levels — **Basic** (60%), **Standard** (80%), **Advanced** (
| [`@dualmark/converters`](./packages/converters) | `npm i @dualmark/converters` | 16 KB | Production-tested converter factories. |
| [`@dualmark/astro`](./packages/astro) | `npm i @dualmark/astro` | 22 KB | Astro 5 integration. Auto-generates `.md` endpoints, ships middleware, generates `llms.txt`. |
| [`@dualmark/nextjs`](./packages/nextjs) | `npm i @dualmark/nextjs` | 15 KB | Next.js App Router adapter. `withDualmark()`, `createDualmarkMiddleware()`, `createDualmarkRouteHandler()`, `createLlmsTxtHandler()`. |
| [`@dualmark/remix`](./packages/remix) | `npm i @dualmark/remix` | 12 KB | React Router v7 Framework Mode adapter. Generated markdown resource routes plus `entry.server` negotiation. |
| [`@dualmark/sveltekit`](./packages/sveltekit) | `npm i @dualmark/sveltekit` | 19 KB | SvelteKit adapter. Vite route generator, `createDualmarkHandle()`, generated `.md` endpoints, and `llms.txt`. |
| [`@dualmark/nuxt`](./packages/nuxt) | `npm i @dualmark/nuxt` | 15 KB | Nuxt module. Auto-generates `.md` endpoints, injects `Link rel="alternate"` middleware, generates `llms.txt`. |
| [`@dualmark/cloudflare`](./packages/cloudflare) | `npm i @dualmark/cloudflare` | 9 KB | Workers edge adapter. Wraps any upstream Worker. Hooks for analytics + telemetry. |
Expand All @@ -322,7 +359,7 @@ Plus:
- [**`spec/`**](./spec) — the **AEO Specification v1.0**. Public, framework-agnostic, RFC-2119-compliant. Implement it in Go, Rust, PHP, Ruby — your call.
- [**`apps/docs/`**](./apps/docs) — Fumadocs site at [dualmark.dev](https://dualmark.dev)
- [**`apps/docs/app/play`**](./apps/docs/app/play) — interactive Accept-header + UA tester. Live at [dualmark.dev/play](https://dualmark.dev/play).
- [**`examples/`**](./examples) — ten end-to-end working examples (Astro, Astro+Cloudflare, Sanity+Astro, Next.js, SvelteKit, Nuxt, Deno, Netlify, Fastly, Vercel).
- [**`examples/`**](./examples) — eleven end-to-end working examples (Astro, Astro+Cloudflare, Sanity+Astro, Next.js, React Router, SvelteKit, Nuxt, Deno, Netlify, Fastly, Vercel).

---

Expand All @@ -338,6 +375,7 @@ Plus:
| `@dualmark/cli` | 25 tests pass |
| `@dualmark/astro` | 39 tests pass |
| `@dualmark/nextjs` | 47 tests pass |
| `@dualmark/remix` | 24 tests pass |
| `@dualmark/sveltekit` | 17 tests pass |
| `@dualmark/nuxt` | 37 tests pass |
| `@dualmark/netlify` | 28 tests pass |
Expand All @@ -346,6 +384,7 @@ Plus:
| `examples/sanity-astro` | **80/80** under `astro dev` (`--skip-negotiation`) for fixture-backed blog + glossary docs |
| `examples/astro-cloudflare-full` | **125/125 perfect** under `wrangler dev` (full negotiation) |
| `examples/nextjs-app-router` | **120/125** under `next dev` (now using `@dualmark/nextjs`) |
| `examples/remix-blog` | **125/125 perfect** under `react-router dev` (`@dualmark/remix`) |
| `examples/sveltekit-blog` | **125/125 perfect** under `vite dev` (full negotiation) |
| `examples/deno-deploy` | **125/125 perfect** under `deno run` (full negotiation) |
| `examples/fastly-compute` | **125/125 perfect** under `fastly compute serve` (full negotiation) |
Expand All @@ -364,7 +403,6 @@ bun run build && bun run test && bun run typecheck # 407 tests across 9 packag

We're building toward Dualmark being **the** AEO infrastructure for marketing sites — the same way Tailwind became the default for marketing CSS or Vercel for marketing hosting. The roadmap:

- **More framework adapters**: Remix/React Router
- **More converters**: pricing tables, changelog, docs/API reference, status pages, integrations
- **AEO Analytics**: a hosted dashboard on top of the `onAIRequest` hook, so marketing can see which bot reads which page, when
- **Spec evolution toward AEO 1.1+** with structured data hints, per-section markdown anchors, and sitemap.md
Expand Down
13 changes: 12 additions & 1 deletion apps/docs/app/_components/adapters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
NetlifyLogo,
NextLogo,
NuxtLogo,
ReactRouterLogo,
SvelteLogo,
VercelLogo,
} from "@/components/brand-logos";
Expand Down Expand Up @@ -93,6 +94,16 @@ const adapters: Adapter[] = [
accent: "oklch(0.78 0.17 160)",
Logo: NuxtLogo,
},
{
name: "React Router",
pkg: "@dualmark/remix",
desc: "React Router v7 Framework Mode adapter. Generated .md resource routes plus entry.server negotiation.",
install: "bun add @dualmark/remix",
score: "125/125",
status: "Stable",
accent: "oklch(0.74 0.18 250)",
Logo: ReactRouterLogo,
},
{
name: "Deno",
pkg: "@dualmark/deno",
Expand Down Expand Up @@ -146,7 +157,7 @@ export function Adapters() {
<span className="text-[var(--color-accent)]">your stack.</span>
</>
}
description="Nine first-class adapters today. Remix/React Router on the roadmap."
description="Ten first-class adapters today. More platform adapters are on the roadmap."
/>

<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
Expand Down
20 changes: 20 additions & 0 deletions apps/docs/components/brand-logos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,26 @@ export function SvelteLogo({ size = 24, ...props }: LogoProps) {
);
}

export function ReactRouterLogo({ size = 24, ...props }: LogoProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="React Router"
{...props}
>
<circle cx="6" cy="12" r="3" fill="#F44250" />
<circle cx="18" cy="12" r="3" fill="#121212" />
<path d="M8.8 10.9h6.4v2.2H8.8z" fill="currentColor" />
<circle cx="12" cy="6" r="3" fill="#F44250" />
<path d="M10.9 8.8h2.2v2.3h-2.2z" fill="currentColor" />
</svg>
);
}

export function DenoLogo({ size = 24, ...props }: LogoProps) {
return (
<svg
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/content/docs/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"pages": [
"astro",
"nextjs",
"remix",
"sveltekit",
"cloudflare-workers",
"fastly-compute",
Expand All @@ -12,4 +13,4 @@
"deno",
"manual"
]
}
}
120 changes: 120 additions & 0 deletions apps/docs/content/docs/integrations/remix.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
title: React Router
description: Add Dualmark to a React Router v7 Framework Mode app with @dualmark/remix.
---

`@dualmark/remix` is the React Router v7 Framework Mode adapter. The package name keeps the Remix ecosystem name, but the target surface is React Router v7.

It ships:

- `dualmark(config)` Vite plugin that generates `.md` resource route modules and `/llms.txt`
- `dualmarkRoutes(config)` for `app/routes.ts`
- `createDualmarkEntryServer(config)` for `app/entry.server.tsx` negotiation before HTML rendering
- `createDualmarkResourceRoute(config)` and `createLlmsTxtResourceRoute(config)` for generated resource routes

## Install

<Tabs items={["bun", "npm", "yarn"]}>
<Tab value="bun">
```bash
bun add @dualmark/remix @dualmark/core @dualmark/converters
```
</Tab>
<Tab value="npm">
```bash
npm install @dualmark/remix @dualmark/core @dualmark/converters
```
</Tab>
<Tab value="yarn">
```bash
yarn add @dualmark/remix @dualmark/core @dualmark/converters
```
</Tab>
</Tabs>

## Config

```ts title="app/dualmark.config.ts"
import type { DualmarkRemixConfig } from "@dualmark/remix";

const config: DualmarkRemixConfig = {
siteUrl: "https://example.com",
collections: {
posts: {
converter: "blog",
route: "posts",
slugStrategy: "single",
getEntries: () => yourPosts,
},
},
staticPages: [{ pattern: "/", render: () => "# Home\n\nWelcome." }],
llmsTxt: { enabled: true, brandName: "Example", sections: [] },
};

export default config;
```

## Vite plugin

Add the Dualmark plugin before React Router so generated route modules exist before React Router reads `app/routes.ts`.

```ts title="vite.config.ts"
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import dualmark from "@dualmark/remix";
import dualmarkConfig from "./app/dualmark.config";

export default defineConfig({
plugins: [dualmark(dualmarkConfig), reactRouter()],
});
```

## Routes

```ts title="app/routes.ts"
import { index, route, type RouteConfig } from "@react-router/dev/routes";
import { dualmarkRoutes } from "@dualmark/remix/routes";
import dualmarkConfig from "./dualmark.config";

export default [
index("routes/home.tsx"),
route("posts", "routes/posts.tsx"),
route("posts/:slug", "routes/post.tsx"),
...dualmarkRoutes(dualmarkConfig),
] satisfies RouteConfig;
```

## Entry server

```tsx title="app/entry.server.tsx"
import { ServerRouter } from "react-router";
import { renderToReadableStream } from "react-dom/server";
import { createDualmarkEntryServer } from "@dualmark/remix";
import dualmarkConfig from "./dualmark.config";

const withDualmark = createDualmarkEntryServer(dualmarkConfig);

export default withDualmark(async function handleRequest(
request,
responseStatusCode,
responseHeaders,
routerContext,
) {
const body = await renderToReadableStream(
<ServerRouter context={routerContext} url={request.url} />,
);
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
return new Response(body, { status: responseStatusCode, headers: responseHeaders });
});
```

## Verify

```bash
curl -sI http://localhost:5174/posts/hello
curl -sI -H "User-Agent: GPTBot/1.0" -H "Accept: text/markdown" http://localhost:5174/posts/hello
curl -sI http://localhost:5174/posts/hello.md
dualmark verify http://localhost:5174/posts/hello
```

The reference example lives at `examples/remix-blog`.
3 changes: 2 additions & 1 deletion apps/docs/content/docs/packages/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"converters",
"astro",
"nextjs",
"remix",
"sveltekit",
"cloudflare",
"fastly",
Expand All @@ -14,4 +15,4 @@
"deno",
"cli"
]
}
}
83 changes: 83 additions & 0 deletions apps/docs/content/docs/packages/remix.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
title: "@dualmark/remix"
description: React Router v7 Framework Mode adapter - generated markdown resource routes and entry.server negotiation.
---

<Tabs items={["bun", "npm", "yarn"]}>
<Tab value="bun">
```bash
bun add @dualmark/remix @dualmark/core @dualmark/converters
```
</Tab>
<Tab value="npm">
```bash
npm install @dualmark/remix @dualmark/core @dualmark/converters
```
</Tab>
<Tab value="yarn">
```bash
yarn add @dualmark/remix @dualmark/core @dualmark/converters
```
</Tab>
</Tabs>

## `dualmark(config)`

The default export is a Vite plugin factory. Add it before `@react-router/dev/vite` in `vite.config.ts`.

```ts title="vite.config.ts"
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import dualmark from "@dualmark/remix";
import dualmarkConfig from "./app/dualmark.config";

export default defineConfig({
plugins: [dualmark(dualmarkConfig), reactRouter()],
});
```

The plugin writes generated route modules into `app/.dualmark-remix`. Generated files start with a fixed header comment. Do not copy that header into hand-written files.

## `dualmarkRoutes(config)`

Use this helper from `@dualmark/remix/routes` in `app/routes.ts`.

```ts title="app/routes.ts"
import { index, route, type RouteConfig } from "@react-router/dev/routes";
import { dualmarkRoutes } from "@dualmark/remix/routes";
import dualmarkConfig from "./dualmark.config";

export default [
index("routes/home.tsx"),
route("posts", "routes/posts.tsx"),
route("posts/:slug", "routes/post.tsx"),
...dualmarkRoutes(dualmarkConfig),
] satisfies RouteConfig;
```

## `createDualmarkEntryServer(config)`

Wrap your `entry.server.tsx` handler. The wrapper:

- serves markdown before render for AI bot user agents
- honors `Accept: text/markdown`
- returns `406 Not Acceptable` when the request excludes HTML and markdown
- adds `Link: <...>; rel="alternate"; type="text/markdown"` and `Vary: Accept` to HTML responses

## Config

```ts
interface DualmarkRemixConfig {
siteUrl: string;
configPath?: string; // default: app/dualmark.config.ts
generatedDir?: string; // default: app/.dualmark-remix
collections?: Record<string, CollectionConfig>;
staticPages?: StaticPageConfig[];
parameterizedRoutes?: ParameterizedRouteConfig[];
llmsTxt?: { enabled?: boolean; brandName?: string; sections?: LlmsTxtSection[] };
middleware?: { injectLinkHeader?: boolean; skipPaths?: ReadonlyArray<string> };
headers?: { cacheControl?: string; noindex?: boolean };
}
```

`slugStrategy: "single"` is supported today. `slugStrategy: "catch-all"` is rejected because React Router v7 splats only match safely at the end of a route (`docs/*`). That route would also catch human HTML paths such as `/docs/a/b`, so the adapter does not generate it.
Loading
Loading