diff --git a/.gitignore b/.gitignore
index 0948c98..c5ebfa2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,7 @@ public/_pagefind/
# misc
.DS_Store
*.pem
+*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
diff --git a/archive/README.md b/archive/README.md
new file mode 100644
index 0000000..1ef7c27
--- /dev/null
+++ b/archive/README.md
@@ -0,0 +1,45 @@
+# Archived templates
+
+The six themes here — `aurora`, `base`, `cedar`, `fern`, `galley`, `mono` —
+are the pre-0.4.0 template set, kept as a historical reference. They were
+archived in `8245b0a`. The templates the CLI actually scaffolds from live in
+`../templates` (`canopy`, `galley`, `shadcn`).
+
+## These do not build as-is
+
+Each of the six depends on `@inkform/framework: ^0.3.0`. On a 0.x version a
+caret range cannot cross the minor, so `^0.3.0` resolves to the published
+`0.3.0` — while the source in these directories was written against the
+newer API that shipped in `0.4.0`. A standalone `npm install && npm run
+build` in any of them fails at module resolution:
+
+ Module not found: Can't resolve '@inkform/framework/ai'
+ Module not found: Can't resolve '@inkform/framework/openapi-render'
+ Module not found: Can't resolve '@inkform/framework/openapi-engine/nav'
+ Module not found: Can't resolve '@inkform/framework/openapi-engine/parse'
+
+`tsc --noEmit` fails for the same reason, plus two API-shape mismatches: the
+templates pass a `contentType` prop to `DocsShell` and read `apiReference`
+off `DocsTab`, neither of which exists in 0.3.0.
+
+This is expected for archived code and is **not** a regression. It is
+recorded here so the next person to open this directory doesn't spend time
+diagnosing it, and so a current-looking dependency version isn't mistaken
+for a maintained template.
+
+Reviving one of these means re-pointing it at `@inkform/framework: ^0.4.0`
+and reconciling the template source with the 0.4.0 API — see
+[`../MIGRATION.md`](../MIGRATION.md) and the 0.4.0 entry in
+[`../CHANGELOG.md`](../CHANGELOG.md).
+
+## Why dependency versions here still get bumped
+
+`archive/templates/*` is outside the root `package.json`'s `workspaces`
+array, so nothing here is installed, typechecked, or built by `npm ci` or by
+CI. But Dependabot reads every `package.json` in the repo regardless, so
+these six manifests still raise alerts and still need patching — as of the
+Next.js 16.2.12 bump they accounted for 54 of the repo's 60 open alerts.
+
+Bumping a version string here is therefore an alert-hygiene change with no
+effect on any build. It does not imply the template was retested; the
+breakage described above applies before and after.
diff --git a/archive/templates/aurora/package.json b/archive/templates/aurora/package.json
index 7261cea..48b215e 100644
--- a/archive/templates/aurora/package.json
+++ b/archive/templates/aurora/package.json
@@ -14,7 +14,7 @@
"dependencies": {
"@inkform/framework": "^0.3.0",
"lucide-react": "^0.483.0",
- "next": "16.2.10",
+ "next": "16.2.12",
"react": "19.2.1",
"react-dom": "19.2.1"
},
diff --git a/archive/templates/base/package.json b/archive/templates/base/package.json
index c46a3e8..e550cb5 100644
--- a/archive/templates/base/package.json
+++ b/archive/templates/base/package.json
@@ -14,7 +14,7 @@
"dependencies": {
"@inkform/framework": "^0.3.0",
"lucide-react": "^0.483.0",
- "next": "16.2.10",
+ "next": "16.2.12",
"react": "19.2.1",
"react-dom": "19.2.1"
},
diff --git a/archive/templates/cedar/package.json b/archive/templates/cedar/package.json
index bb331e9..2f317ee 100644
--- a/archive/templates/cedar/package.json
+++ b/archive/templates/cedar/package.json
@@ -14,7 +14,7 @@
"dependencies": {
"@inkform/framework": "^0.3.0",
"lucide-react": "^0.483.0",
- "next": "16.2.10",
+ "next": "16.2.12",
"react": "19.2.1",
"react-dom": "19.2.1"
},
diff --git a/archive/templates/fern/package.json b/archive/templates/fern/package.json
index ef3b1d4..2400716 100644
--- a/archive/templates/fern/package.json
+++ b/archive/templates/fern/package.json
@@ -14,7 +14,7 @@
"dependencies": {
"@inkform/framework": "^0.3.0",
"lucide-react": "^0.483.0",
- "next": "16.2.10",
+ "next": "16.2.12",
"react": "19.2.1",
"react-dom": "19.2.1"
},
diff --git a/archive/templates/galley/package.json b/archive/templates/galley/package.json
index 6e2340f..59db239 100644
--- a/archive/templates/galley/package.json
+++ b/archive/templates/galley/package.json
@@ -14,7 +14,7 @@
"dependencies": {
"@inkform/framework": "^0.3.0",
"lucide-react": "^0.483.0",
- "next": "16.2.10",
+ "next": "16.2.12",
"react": "19.2.1",
"react-dom": "19.2.1"
},
diff --git a/archive/templates/mono/package.json b/archive/templates/mono/package.json
index 1899fcb..98fe3da 100644
--- a/archive/templates/mono/package.json
+++ b/archive/templates/mono/package.json
@@ -14,7 +14,7 @@
"dependencies": {
"@inkform/framework": "^0.3.0",
"lucide-react": "^0.483.0",
- "next": "16.2.10",
+ "next": "16.2.12",
"react": "19.2.1",
"react-dom": "19.2.1"
},
diff --git a/examples/inkform-docs/app/[[...slug]]/page.tsx b/examples/inkform-docs/app/[[...slug]]/page.tsx
index 77d58bc..6d4e98d 100644
--- a/examples/inkform-docs/app/[[...slug]]/page.tsx
+++ b/examples/inkform-docs/app/[[...slug]]/page.tsx
@@ -1,8 +1,9 @@
import { notFound } from 'next/navigation';
import { Mdx } from '@inkform/framework/mdx';
import { DocsShell, TocList, Pagination } from '@inkform/framework/docs-shell';
+import { PageActions } from '@inkform/framework/page-actions';
import { docNeighbours } from '@inkform/framework';
-import { loadDocsConfig, extractHeadings } from '@inkform/framework/content';
+import { loadDocsConfig, extractHeadings, stripLeadingH1 } from '@inkform/framework/content';
import { siteMdxComponents } from '@/mdx-components';
import { buildTopBar } from '@/components/top-bar';
import { CollapsibleSidebar } from '@/components/collapsible-sidebar';
@@ -57,7 +58,11 @@ export default async function Page({ params }: { params: Promise<{ slug?: string
toc={headings.length > 0 ? : undefined}
hideToc={headings.length === 0}
>
-
+
+ .md — any page as Markdown, served at the page's own URL plus a
+ * `.md` extension (e.g. /getting-started/quickstart.md). Index = /index.md.
+ * API operations resolve as //operations/.md; blog and
+ * changelog entries as /blog/.md and /changelog/.md. Unknown
+ * pages 404.
+ */
+const config = loadDocsConfig();
+const apiBase = config && apiBasePath(config);
+
+export const GET = createMarkdownHandler({ apiBasePath: apiBase ?? undefined });
diff --git a/examples/inkform-docs/proxy.ts b/examples/inkform-docs/proxy.ts
index a0fd617..59aae83 100644
--- a/examples/inkform-docs/proxy.ts
+++ b/examples/inkform-docs/proxy.ts
@@ -4,6 +4,16 @@ import slugHistory from './content/docs/slug-history.json';
/** Docs are served at the root, so slug-history redirects use the '/' base. */
export function proxy(req: NextRequest) {
+ // Any page is also reachable as Markdown by appending `.md` to its own URL
+ // (e.g. /quickstart.md). Route those to the internal markdown handler; the
+ // `NextResponse.rewrite` keeps the `.md` URL in the address bar while the
+ // response comes from app/markdown/[[...slug]]/route.ts.
+ const { pathname } = req.nextUrl;
+ if (pathname.endsWith('.md') && pathname !== '/markdown' && !pathname.startsWith('/markdown/')) {
+ const slug = pathname.slice(1, -3);
+ return NextResponse.rewrite(new URL(`/markdown/${slug}`, req.url));
+ }
+
const target = resolveSlugRedirect(
req.nextUrl.pathname,
slugHistory as Record,
diff --git a/examples/markdown-docs/app/[[...slug]]/page.tsx b/examples/markdown-docs/app/[[...slug]]/page.tsx
index 77d58bc..6d4e98d 100644
--- a/examples/markdown-docs/app/[[...slug]]/page.tsx
+++ b/examples/markdown-docs/app/[[...slug]]/page.tsx
@@ -1,8 +1,9 @@
import { notFound } from 'next/navigation';
import { Mdx } from '@inkform/framework/mdx';
import { DocsShell, TocList, Pagination } from '@inkform/framework/docs-shell';
+import { PageActions } from '@inkform/framework/page-actions';
import { docNeighbours } from '@inkform/framework';
-import { loadDocsConfig, extractHeadings } from '@inkform/framework/content';
+import { loadDocsConfig, extractHeadings, stripLeadingH1 } from '@inkform/framework/content';
import { siteMdxComponents } from '@/mdx-components';
import { buildTopBar } from '@/components/top-bar';
import { CollapsibleSidebar } from '@/components/collapsible-sidebar';
@@ -57,7 +58,11 @@ export default async function Page({ params }: { params: Promise<{ slug?: string
toc={headings.length > 0 ? : undefined}
hideToc={headings.length === 0}
>
-
+
+ .md — any page as Markdown, served at the page's own URL plus a
+ * `.md` extension (e.g. /quickstart.md, /concepts/pagination.md). Index =
+ * /index.md. API operations resolve as
+ * //operations/.md; blog and changelog entries as
+ * /blog/.md and /changelog/.md. Unknown pages 404.
+ */
+const config = loadDocsConfig();
+const apiBase = config && apiBasePath(config);
+
+export const GET = createMarkdownHandler({ apiBasePath: apiBase ?? undefined });
diff --git a/examples/markdown-docs/proxy.ts b/examples/markdown-docs/proxy.ts
index a0fd617..59aae83 100644
--- a/examples/markdown-docs/proxy.ts
+++ b/examples/markdown-docs/proxy.ts
@@ -4,6 +4,16 @@ import slugHistory from './content/docs/slug-history.json';
/** Docs are served at the root, so slug-history redirects use the '/' base. */
export function proxy(req: NextRequest) {
+ // Any page is also reachable as Markdown by appending `.md` to its own URL
+ // (e.g. /quickstart.md). Route those to the internal markdown handler; the
+ // `NextResponse.rewrite` keeps the `.md` URL in the address bar while the
+ // response comes from app/markdown/[[...slug]]/route.ts.
+ const { pathname } = req.nextUrl;
+ if (pathname.endsWith('.md') && pathname !== '/markdown' && !pathname.startsWith('/markdown/')) {
+ const slug = pathname.slice(1, -3);
+ return NextResponse.rewrite(new URL(`/markdown/${slug}`, req.url));
+ }
+
const target = resolveSlugRedirect(
req.nextUrl.pathname,
slugHistory as Record,
diff --git a/examples/pokeapi-docs/app/[[...slug]]/page.tsx b/examples/pokeapi-docs/app/[[...slug]]/page.tsx
index 77d58bc..6d4e98d 100644
--- a/examples/pokeapi-docs/app/[[...slug]]/page.tsx
+++ b/examples/pokeapi-docs/app/[[...slug]]/page.tsx
@@ -1,8 +1,9 @@
import { notFound } from 'next/navigation';
import { Mdx } from '@inkform/framework/mdx';
import { DocsShell, TocList, Pagination } from '@inkform/framework/docs-shell';
+import { PageActions } from '@inkform/framework/page-actions';
import { docNeighbours } from '@inkform/framework';
-import { loadDocsConfig, extractHeadings } from '@inkform/framework/content';
+import { loadDocsConfig, extractHeadings, stripLeadingH1 } from '@inkform/framework/content';
import { siteMdxComponents } from '@/mdx-components';
import { buildTopBar } from '@/components/top-bar';
import { CollapsibleSidebar } from '@/components/collapsible-sidebar';
@@ -57,7 +58,11 @@ export default async function Page({ params }: { params: Promise<{ slug?: string
toc={headings.length > 0 ? : undefined}
hideToc={headings.length === 0}
>
-
+
+ .md — any page as Markdown, served at the page's own URL plus a
+ * `.md` extension (e.g. /quickstart.md, /concepts/pagination.md). Index =
+ * /index.md. API operations resolve as
+ * //operations/.md; blog and changelog entries as
+ * /blog/.md and /changelog/.md. Unknown pages 404.
+ */
+const config = loadDocsConfig();
+const apiBase = config && apiBasePath(config);
+
+export const GET = createMarkdownHandler({ apiBasePath: apiBase ?? undefined });
diff --git a/examples/pokeapi-docs/proxy.ts b/examples/pokeapi-docs/proxy.ts
index a0fd617..59aae83 100644
--- a/examples/pokeapi-docs/proxy.ts
+++ b/examples/pokeapi-docs/proxy.ts
@@ -4,6 +4,16 @@ import slugHistory from './content/docs/slug-history.json';
/** Docs are served at the root, so slug-history redirects use the '/' base. */
export function proxy(req: NextRequest) {
+ // Any page is also reachable as Markdown by appending `.md` to its own URL
+ // (e.g. /quickstart.md). Route those to the internal markdown handler; the
+ // `NextResponse.rewrite` keeps the `.md` URL in the address bar while the
+ // response comes from app/markdown/[[...slug]]/route.ts.
+ const { pathname } = req.nextUrl;
+ if (pathname.endsWith('.md') && pathname !== '/markdown' && !pathname.startsWith('/markdown/')) {
+ const slug = pathname.slice(1, -3);
+ return NextResponse.rewrite(new URL(`/markdown/${slug}`, req.url));
+ }
+
const target = resolveSlugRedirect(
req.nextUrl.pathname,
slugHistory as Record,
diff --git a/package-lock.json b/package-lock.json
index 59bcca3..c243a64 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3353,9 +3353,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
- "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -3429,7 +3429,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -3775,9 +3774,9 @@
}
},
"node_modules/hono": {
- "version": "4.12.32",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz",
- "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==",
+ "version": "4.13.1",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz",
+ "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -4013,9 +4012,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "3.15.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
- "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
+ "version": "3.15.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
+ "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
@@ -5270,9 +5269,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.16",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
- "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
diff --git a/package.json b/package.json
index 394c43e..2a5b705 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"prettier": "^3.5.1"
},
"overrides": {
- "js-yaml": "^3.15.0",
+ "js-yaml": "^3.15.1",
"next": {
"postcss": "8.5.23",
"sharp": "0.35.3"
diff --git a/packages/framework/package.json b/packages/framework/package.json
index 4d27b2c..931da9e 100644
--- a/packages/framework/package.json
+++ b/packages/framework/package.json
@@ -45,6 +45,7 @@
"./ai": "./src/ai/index.ts",
"./llms-txt": "./src/llms-txt.ts",
"./mdx": "./src/mdx.tsx",
+ "./markdown": "./src/markdown.ts",
"./components": "./src/components.tsx",
"./docs-shell": "./src/docs-shell.tsx",
"./scalar-theme": "./src/scalar-theme.ts",
@@ -52,6 +53,7 @@
"./pagefind-highlight": "./src/pagefind-highlight-mount.tsx",
"./ask-ai": "./src/ask-ai.tsx",
"./ai-tool-menu": "./src/ai-tool-menu.tsx",
+ "./page-actions": "./src/page-actions.tsx",
"./theme-toggle": "./src/theme-toggle.tsx",
"./subscribe-form": "./src/subscribe-form.tsx",
"./analytics-script": "./src/analytics-script.tsx",
diff --git a/packages/framework/src/ai-tool-icons.tsx b/packages/framework/src/ai-tool-icons.tsx
new file mode 100644
index 0000000..c5f3faa
--- /dev/null
+++ b/packages/framework/src/ai-tool-icons.tsx
@@ -0,0 +1,97 @@
+import * as React from 'react';
+import type { AiToolId } from './ai-tools';
+
+/**
+ * Built-in monochrome brand marks for the AI-tool registry (./ai-tools),
+ * used as the fallback icons in AiToolMenu / ViewOptionsPopover.
+ *
+ * These are single-color (fill="currentColor") glyphs — not the multi-color
+ * brand logos — so they inherit the theme's text color exactly like the
+ * arrow/copy glyphs they replace. Marks are the monochrome variants from
+ * thesvg.org (https://thesvg.org, free brand SVG set). A theme can still
+ * override any of them via the `icons` prop.
+ */
+
+interface BrandGlyphProps {
+ /** SVG path data. */
+ path: string;
+ /** fill-rule for the path; `evenodd` for marks that need it. */
+ fillRule?: 'evenodd';
+ /** SVG viewBox; defaults to the standard `0 0 24 24`. */
+ viewBox?: string;
+}
+
+function BrandGlyph({ path, fillRule, viewBox = '0 0 24 24' }: BrandGlyphProps) {
+ return (
+
+ );
+}
+
+/** Built-in monochrome brand marks, keyed by `AiToolId`. */
+export const defaultAiToolIcons: Partial> = {
+ chatgpt: (
+
+ ),
+ claude: (
+
+ ),
+ google: (
+
+ ),
+ cursor: (
+
+ ),
+ // Official VS Code 1.35+ logo silhouette (Wikimedia Commons
+ // "Visual_Studio_Code_1.35_icon.svg"), filled as a monochrome mark.
+ vscode: (
+
+ ),
+ opencode: (
+
+ ),
+ 'claude-code': (
+
+ ),
+ codex: (
+
+ ),
+ antigravity: (
+
+ ),
+ perplexity: (
+
+ ),
+ grok: (
+
+ ),
+};
diff --git a/packages/framework/src/ai-tool-menu.tsx b/packages/framework/src/ai-tool-menu.tsx
index 7ae6373..df8e318 100644
--- a/packages/framework/src/ai-tool-menu.tsx
+++ b/packages/framework/src/ai-tool-menu.tsx
@@ -1,6 +1,11 @@
'use client';
import * as React from 'react';
+import { AI_TOOLS, buildAiToolAction, buildPrompt, safeOrigin, type AiToolId } from './ai-tools';
+import { defaultAiToolIcons } from './ai-tool-icons';
+import { Confetti } from './confetti';
+import { CopyGlyph, CheckGlyph, ExternalGlyph } from './glyphs';
+import { copyText } from './clipboard';
/**
* AiToolMenu — a right-rail list of "hand this page to an AI tool" actions:
@@ -8,10 +13,8 @@ import * as React from 'react';
* pre-filled prompt), Connect to Cursor/VS Code (installs THIS SITE'S OWN
* MCP server — see '@inkform/framework/mcp' — into the reader's editor).
*
- * Modeled on the right-rail menu at sequoia.mintlify.site (verified by
- * directly inspecting that site's own shipped implementation — intercepting
- * `window.open`/`navigator.clipboard.writeText` calls rather than guessing —
- * see the comment on each URL builder below for what was actually observed).
+ * Tool definitions (labels, URLs, query params) live in ./ai-tools — a data
+ * registry, not per-tool functions.
* Ties into this framework's existing llms.txt/MCP work: the Cursor/VS Code
* items are only meaningful because a theme can mount `createMcpHandler()`
* (./mcp) at a real route in a couple of lines; this component is otherwise
@@ -20,16 +23,18 @@ import * as React from 'react';
*
* Framework components don't bundle an icon library (see ARCHITECTURE.md
* §5) — `renderIcon` follows the same convention as Sidebar/DocsShell's own
- * `renderIcon` prop. Without one, a small brand-neutral built-in glyph is
- * used (a generic "copy" icon, and a generic external-link arrow for every
- * other item) rather than reproducing any tool's actual logo mark.
+ * `renderIcon` prop. Without one, a small monochrome brand mark per tool
+ * (./ai-tool-icons, single-color `currentColor` glyphs from thesvg.org) is
+ * used rather than reproducing any tool's multi-color logo, so icons inherit
+ * the theme's text color just like the previous generic arrow/copy glyphs.
*/
/* ─────────────────────────────────────────────
Types
───────────────────────────────────────────── */
-export type AiToolId = 'copy' | 'chatgpt' | 'claude' | 'cursor' | 'vscode' | 'perplexity' | 'grok';
+export type { AiToolId };
+export { buildPrompt };
export interface AiToolMenuProps {
/**
@@ -62,7 +67,7 @@ export interface AiToolMenuProps {
/** Section heading, or `null` to omit it (e.g. stacking under a TocList that already renders "On this page"). */
title?: string | null;
/**
- * Pre-rendered icon per tool (e.g. Lucide elements), keyed by `AiToolId`.
+ * Pre-rendered icon per tool (e.g. icon-library elements), keyed by `AiToolId`.
* A plain ReactNode map rather than a `renderIcon` callback — this
* component is a Client Component, and a live function prop can't cross
* the Server → Client Component boundary from a page.tsx that builds this
@@ -71,152 +76,14 @@ export interface AiToolMenuProps {
* map once with real icons (e.g. a small constant in lib/icons.tsx) and
* pass it down as data, the same way Sidebar/DocsShell's own `renderIcon`
* convention resolves icons into ReactNode server-side before they ever
- * reach a component. Falls back to a small built-in glyph per tool for any
- * id not present in the map.
+ * reach a component. Falls back to a small monochrome brand mark per tool
+ * (./ai-tool-icons) for any id not present in the map.
*/
icons?: Partial>;
/** Extra class name on the root
);
diff --git a/packages/framework/src/ai-tools.ts b/packages/framework/src/ai-tools.ts
new file mode 100644
index 0000000..a32ab7d
--- /dev/null
+++ b/packages/framework/src/ai-tools.ts
@@ -0,0 +1,151 @@
+/**
+ * AI tool registry — the "hand this page to an AI tool" actions used by both
+ * AiToolMenu and PageActions. Pure data + one resolver, instead of one
+ * function per tool.
+ *
+ * Three kinds:
+ * - `prompt` — a web tool that takes the page URL and pre-fills a prompt in a
+ * query param (`Ask ChatGPT`, `Ask Claude`, ...). Opens as a link.
+ * - `mcp` — a local editor deeplink that installs THIS SITE'S OWN MCP server
+ * (see '@inkform/framework/mcp') into the reader's editor (`Open in
+ * Cursor`, `Open in VS Code`). Opens as a link.
+ * - `command` — a local CLI tool; clicking copies a terminal command the
+ * reader pastes into their own shell (`opencode run "…"`, `claude "…"`,
+ * `codex exec "…"`, `agy -p "…"`).
+ * Used where a reliable deep link doesn't exist (e.g. claude-code's
+ * `claude-cli://` being stripped on some hosts).
+ */
+
+export type AiToolId =
+ | 'chatgpt'
+ | 'claude'
+ | 'google'
+ | 'cursor'
+ | 'vscode'
+ | 'opencode'
+ | 'claude-code'
+ | 'codex'
+ | 'antigravity'
+ | 'perplexity'
+ | 'grok';
+
+interface PromptTool {
+ kind: 'prompt';
+ id: AiToolId;
+ label: string;
+ /** Base URL, e.g. `https://chatgpt.com/`. */
+ base: string;
+ /** Query param the prompt goes in, e.g. `prompt` or `q`. */
+ param: string;
+ /** Extra fixed query params, e.g. `{ hints: 'search' }`. */
+ extra?: Record;
+}
+
+interface McpTool {
+ kind: 'mcp';
+ id: AiToolId;
+ label: string;
+ /** How to encode the MCP-install deeplink. */
+ format: 'cursor' | 'vscode';
+}
+
+interface CommandTool {
+ kind: 'command';
+ id: AiToolId;
+ label: string;
+ /** Terminal command to copy; `{prompt}` is replaced with the quoted prompt. */
+ command: string;
+}
+
+export type AiTool = PromptTool | McpTool | CommandTool;
+
+/** The prompt every tool receives — read the page, ask about it. */
+export function buildPrompt(pageUrl: string): string {
+ return `Read ${pageUrl} and help me understand it`;
+}
+
+export const AI_TOOLS: AiTool[] = [
+ { kind: 'prompt', id: 'chatgpt', label: 'Ask ChatGPT', base: 'https://chatgpt.com/', param: 'prompt', extra: { hints: 'search' } },
+ { kind: 'prompt', id: 'claude', label: 'Ask Claude', base: 'https://claude.ai/new', param: 'q' },
+ { kind: 'prompt', id: 'google', label: 'Ask Google', base: 'https://www.google.com/search', param: 'q' },
+ { kind: 'mcp', id: 'cursor', label: 'Open in Cursor', format: 'cursor' },
+ { kind: 'mcp', id: 'vscode', label: 'Open in VS Code', format: 'vscode' },
+ { kind: 'command', id: 'opencode', label: 'OpenCode command', command: 'opencode run "{prompt}"' },
+ { kind: 'command', id: 'claude-code', label: 'Claude Code command', command: 'claude "{prompt}"' },
+ { kind: 'command', id: 'codex', label: 'Codex command', command: 'codex exec "{prompt}"' },
+ { kind: 'command', id: 'antigravity', label: 'Antigravity command', command: 'agy -p "{prompt}"' },
+ { kind: 'prompt', id: 'perplexity', label: 'Ask Perplexity', base: 'https://www.perplexity.ai/search', param: 'q' },
+ { kind: 'prompt', id: 'grok', label: 'Ask Grok', base: 'https://grok.com/', param: 'q' },
+];
+
+/** Unicode-safe base64 (btoa() alone only handles Latin1) — guards a siteName with non-ASCII characters. */
+function safeBase64(text: string): string {
+ const bytes = new TextEncoder().encode(text);
+ let binary = '';
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary);
+}
+
+export function safeOrigin(url: string): string | undefined {
+ try {
+ return new URL(url).origin;
+ } catch {
+ return undefined;
+ }
+}
+
+function mcpInstallHref(format: McpTool['format'], siteName: string, mcpUrl: string): string {
+ if (format === 'cursor') {
+ // Cursor's documented one-click MCP install deep link:
+ // cursor://anysphere.cursor-deeplink/mcp/install?name=&config=
+ // The config is the mcp.json transport entry for a remote server — just
+ // { url } (Cursor infers the HTTP transport from the `url` field).
+ const config = safeBase64(JSON.stringify({ url: mcpUrl }));
+ return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(siteName)}&config=${config}`;
+ }
+ // VS Code's documented MCP install URI: vscode:mcp/install?
+ // — note the query segment IS the encoded JSON, not key=value pairs. The
+ // JSON is the mcp.json server entry, so a URL server needs `"type":"http"`
+ // (VS Code requires the transport type; without it the entry falls back to
+ // stdio and the `url` field is invalid).
+ return `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: siteName, type: 'http', url: mcpUrl }))}`;
+}
+
+export interface BuildAiToolHrefOptions {
+ /** Page URL — required for `prompt` tools. */
+ pageUrl?: string;
+ /** MCP endpoint — required for the Cursor/VS Code MCP install links. */
+ mcpUrl?: string;
+ /** Shown to Cursor/VS Code as the installed MCP server's label. Defaults to 'Docs'. */
+ siteName?: string;
+}
+
+/**
+ * The reader-facing action for one tool: a link to open, or a terminal command
+ * to copy. Returns null when a prerequisite is missing.
+ */
+export type AiToolAction =
+ | { type: 'link'; href: string }
+ | { type: 'command'; command: string };
+
+/** Build one tool's action from the registry. Returns null when a prerequisite is missing. */
+export function buildAiToolAction(tool: AiTool, options: BuildAiToolHrefOptions): AiToolAction | null {
+ if (tool.kind === 'mcp') {
+ if (!options.mcpUrl) return null;
+ return { type: 'link', href: mcpInstallHref(tool.format, options.siteName ?? 'Docs', options.mcpUrl) };
+ }
+ if (tool.kind === 'command') {
+ if (!options.pageUrl) return null;
+ return {
+ type: 'command',
+ command: tool.command.replace('{prompt}', JSON.stringify(buildPrompt(options.pageUrl))),
+ };
+ }
+ if (!options.pageUrl) return null;
+ const url = new URL(tool.base);
+ url.searchParams.set(tool.param, buildPrompt(options.pageUrl));
+ for (const [key, value] of Object.entries(tool.extra ?? {})) {
+ url.searchParams.set(key, value);
+ }
+ return { type: 'link', href: url.toString() };
+}
diff --git a/packages/framework/src/clipboard.ts b/packages/framework/src/clipboard.ts
new file mode 100644
index 0000000..76bb73d
--- /dev/null
+++ b/packages/framework/src/clipboard.ts
@@ -0,0 +1,27 @@
+/**
+ * Copy text to the clipboard, with a fallback for contexts without the async
+ * Clipboard API (e.g. non-HTTPS dev over a LAN IP).
+ */
+export async function copyText(text: string): Promise {
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ } catch {
+ // fall through to the legacy path below
+ }
+ try {
+ const el = document.createElement('textarea');
+ el.value = text;
+ el.style.position = 'fixed';
+ el.style.opacity = '0';
+ document.body.appendChild(el);
+ el.select();
+ document.execCommand('copy');
+ document.body.removeChild(el);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/framework/src/confetti.tsx b/packages/framework/src/confetti.tsx
new file mode 100644
index 0000000..ac80149
--- /dev/null
+++ b/packages/framework/src/confetti.tsx
@@ -0,0 +1,35 @@
+import * as React from 'react';
+
+/**
+ * A tiny confetti burst shown inside an icon span when a copy action
+ * succeeds ("Copied!" state). Pure CSS — 10 small colored pieces that launch
+ * outward from the icon and fade, driven by per-piece CSS custom properties.
+ * Falls back to invisible under `prefers-reduced-motion` (see layout.css).
+ *
+ * Piece colors are the `--fw-confetti-*` tokens declared on `.fw-confetti`
+ * in layout.css, so a theme can restyle them without touching this file.
+ *
+ * Parent must be `position: relative` (the icon spans already are, e.g.
+ * .fw-aitoolmenu-icon / .fw-page-action-menu-icon).
+ */
+const CONFETTI_COLORS = ['var(--fw-confetti-1)', 'var(--fw-confetti-2)', 'var(--fw-confetti-3)', 'var(--fw-confetti-4)', 'var(--fw-confetti-5)'];
+
+export function Confetti() {
+ return (
+
+ {Array.from({ length: 10 }, (_, i) => {
+ const angle = (i / 10) * Math.PI * 2;
+ const dist = 14 + (i % 3) * 5;
+ const style = {
+ '--tx': `${Math.cos(angle) * dist}px`,
+ '--ty': `${Math.sin(angle) * dist}px`,
+ '--rot': `${(i % 2 ? 1 : -1) * (40 + (i % 5) * 18)}deg`,
+ '--delay': `${(i % 4) * 35}ms`,
+ '--color': CONFETTI_COLORS[i % CONFETTI_COLORS.length],
+ '--size': `${2 + (i % 2)}px`,
+ } as React.CSSProperties;
+ return ;
+ })}
+
+ );
+}
diff --git a/packages/framework/src/content.ts b/packages/framework/src/content.ts
index b3ced13..9016488 100644
--- a/packages/framework/src/content.ts
+++ b/packages/framework/src/content.ts
@@ -278,6 +278,11 @@ export function loadSlugHistory(dir = 'docs'): Record {
export type Heading = { depth: number; text: string; slug: string };
+/** Remove a leading `# H1` line — used when the page shell renders the title itself. */
+export function stripLeadingH1(content: string): string {
+ return content.replace(/^\s*#\s+.+[\r\n]+/, '');
+}
+
/** Slugify a heading the same way `rehype-slug`/GitHub do, for anchor links. */
export function slugify(text: string): string {
return text
diff --git a/packages/framework/src/glyphs.tsx b/packages/framework/src/glyphs.tsx
new file mode 100644
index 0000000..b441438
--- /dev/null
+++ b/packages/framework/src/glyphs.tsx
@@ -0,0 +1,51 @@
+import * as React from 'react';
+
+/**
+ * Shared dependency-free stroke glyphs used by the AI-tool menu and page
+ * actions (copy / check / external-link / text icons). Single-color
+ * `currentColor` strokes, sized to match the menu's 15px icon slots.
+ */
+
+function Glyph({ children }: { children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+export function CopyGlyph() {
+ return (
+
+
+
+
+ );
+}
+
+export function CheckGlyph() {
+ return (
+
+
+
+ );
+}
+
+export function ExternalGlyph() {
+ return (
+
+
+
+
+ );
+}
+
+export function TextGlyph() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/framework/src/markdown.test.ts b/packages/framework/src/markdown.test.ts
new file mode 100644
index 0000000..069de26
--- /dev/null
+++ b/packages/framework/src/markdown.test.ts
@@ -0,0 +1,167 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { buildMarkdownPage, createMarkdownHandler, processMarkdown } from './markdown';
+
+const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
+const POKEAPI_CONTENT_ROOT = path.join(REPO_ROOT, 'examples', 'pokeapi-docs', 'content');
+
+describe('processMarkdown', () => {
+ it('strips imports/exports but keeps the body', () => {
+ const md = processMarkdown(
+ `import { Playground } from '@/widgets';
+import { SomeComponent } from './other.mdx';
+
+# Hello
+
+Some text.
+`,
+ );
+ expect(md).not.toMatch(/import/);
+ expect(md).toContain('# Hello');
+ expect(md).toContain('Some text.');
+ });
+
+ it('keeps self-closing data components as JSX markers (props are data)', () => {
+ const md = processMarkdown(
+ `# Title
+
+
+
+Get a Pokémon
+`,
+ );
+ expect(md).toContain('');
+ // ApiLink HAS children, so it's treated as a wrapper — its text survives.
+ expect(md).toContain('Get a Pokémon');
+ expect(md).not.toContain(' {
+ const md = processMarkdown(
+ `
+
+Alpha content
+
+Beta content
+
+
+`,
+ );
+ expect(md).toContain('Alpha content');
+ expect(md).toContain('Beta content');
+ expect(md).not.toContain(' {
+ const md = processMarkdown(`:::info
+
+Callout body here.
+
+:::
+`);
+ expect(md).toMatch(/^> /);
+ expect(md).toContain('Callout body here.');
+ });
+
+ it('keeps fenced code and GFM tables', () => {
+ const md = processMarkdown(
+ '```ts\nconst x = 1;\n```\n\n| a | b |\n| - | - |\n| 1 | 2 |\n',
+ );
+ expect(md).toContain('```ts');
+ expect(md).toContain('const x = 1;');
+ expect(md).toContain('| a | b |');
+ });
+});
+
+describe('buildMarkdownPage (real pokeapi-docs content)', () => {
+ beforeEach(() => {
+ process.env.DOCS_CONTENT_ROOT = POKEAPI_CONTENT_ROOT;
+ });
+ afterEach(() => {
+ delete process.env.DOCS_CONTENT_ROOT;
+ });
+
+ it('returns a doc page with a title header', async () => {
+ const md = await buildMarkdownPage('quickstart');
+ expect(md).toMatch(/^# Quickstart/);
+ expect(md).toContain('PokéAPI requires zero configuration.');
+ });
+
+ it('returns the index page for an empty slug', async () => {
+ const md = await buildMarkdownPage('');
+ expect(md).toMatch(/^# /);
+ });
+
+ it('returns an API operation under /operations/', async () => {
+ const md = await buildMarkdownPage('api-reference/operations/get-pokemon', {
+ apiBasePath: 'api-reference',
+ });
+ expect(md).toContain('GET /pokemon/{name}');
+ });
+
+ it('returns a blog post under blog/', async () => {
+ const md = await buildMarkdownPage('blog/building-a-pokedex-with-nextjs');
+ expect(md).toMatch(/^# /);
+ });
+
+ it('returns a changelog entry under changelog/', async () => {
+ const md = await buildMarkdownPage('changelog/v1-0');
+ expect(md).toMatch(/^# /);
+ expect(md).toContain('First public version of these docs');
+ });
+
+ it('returns null for unknown slugs', async () => {
+ expect(await buildMarkdownPage('definitely-not-a-page')).toBeNull();
+ expect(await buildMarkdownPage('api-reference/operations/nope', { apiBasePath: 'api-reference' })).toBeNull();
+ expect(await buildMarkdownPage('blog/nope')).toBeNull();
+ });
+});
+
+describe('createMarkdownHandler', () => {
+ beforeEach(() => {
+ process.env.DOCS_CONTENT_ROOT = POKEAPI_CONTENT_ROOT;
+ });
+ afterEach(() => {
+ delete process.env.DOCS_CONTENT_ROOT;
+ });
+
+ const handler = createMarkdownHandler({ apiBasePath: 'api-reference' });
+ // Mimics Next.js passing the resolved [[...slug]] params, the same way it
+ // routes the docs pages themselves.
+ const ctx = (slug?: string[]) => ({ params: Promise.resolve({ slug }) });
+
+ it('serves a doc page as text/markdown', async () => {
+ const res = await handler(new Request('http://localhost/quickstart.md'), ctx(['quickstart']));
+ expect(res.status).toBe(200);
+ expect(res.headers.get('content-type')).toContain('text/markdown');
+ expect(await res.text()).toContain('# Quickstart');
+ });
+
+ it('serves the index page for an empty slug', async () => {
+ const res = await handler(new Request('http://localhost/index.md'), ctx([]));
+ expect(res.status).toBe(200);
+ expect(await res.text()).toMatch(/^# /);
+ });
+
+ it('maps /index to the index page (Next aliases /index → /)', async () => {
+ const res = await handler(new Request('http://localhost/index.md'), ctx(['index']));
+ expect(res.status).toBe(200);
+ expect(await res.text()).toMatch(/^# /);
+ });
+
+ it('serves an operation under /operations/', async () => {
+ const res = await handler(
+ new Request('http://localhost/api-reference/operations/get-pokemon.md'),
+ ctx(['api-reference', 'operations', 'get-pokemon']),
+ );
+ expect(res.status).toBe(200);
+ expect(await res.text()).toContain('GET /pokemon/{name}');
+ });
+
+ it('404s unknown slugs', async () => {
+ const res = await handler(new Request('http://localhost/definitely-not-a-page.md'), ctx(['definitely-not-a-page']));
+ expect(res.status).toBe(404);
+ });
+});
diff --git a/packages/framework/src/markdown.ts b/packages/framework/src/markdown.ts
new file mode 100644
index 0000000..e2b3fa2
--- /dev/null
+++ b/packages/framework/src/markdown.ts
@@ -0,0 +1,309 @@
+/**
+ * @inkform/framework — per-page Markdown (`*.md`).
+ *
+ * Any page on the site is also reachable as a single Markdown document by
+ * appending `.md` to its own URL: `/quickstart.md`, `/index.md`,
+ * `/concepts/pagination.md`, `/api-reference/operations/get-pokemon.md`. A
+ * middleware rewrite (in the app's `proxy.ts`) maps those `.md` URLs onto the
+ * internal markdown route; the public URL keeps its `.md` suffix.
+ *
+ * Three pieces:
+ *
+ * 1. `processMarkdown()` — converts an MDX source string (the body an author
+ * commits, e.g. `loadDocPage().content`) into a cleaned, LLM-readable
+ * Markdown string. It parses with the SAME plugins `` renders with
+ * (remark-gfm, remark-directive, remark-mdx) and re-stringifies the
+ * resulting mdast via `mdast-util-to-markdown` — deliberately NOT a regex
+ * strip, which would silently lose data-bearing components.
+ *
+ * Component policy — structural, no hardcoded component or attribute
+ * names (the site's own widgets are unknown to this package):
+ * - `mdxjsEsm` (imports/exports) → removed. This is the "a component that
+ * imports another component / another .mdx" answer: the import machinery
+ * disappears, but every `` *use site* stays in the output.
+ * - `:::callout` directives → blockquote (the closest markdown-native
+ * shape for what `` maps onto ``).
+ * - A component WITH children → a wrapper; only the inner content is kept.
+ * If any attribute value looks like a URL (by value — `href`, `url`,
+ * `src`, anything), it's surfaced as a markdown link; if one reads as a
+ * human label (contains a space or uppercase), it's surfaced as bold.
+ * - A self-closing component (no children) → the JSX tag is kept with its
+ * attributes, since props are its only content: ``, ``. `{expr}`
+ * expressions are left as-is (unresolvable without executing the
+ * component).
+ *
+ * 2. `buildMarkdownPage()` — resolves a slug to the Markdown for ONE page:
+ * a doc page, an API operation (`/operations/`), a
+ * blog post, or a changelog entry. The per-page counterpart to
+ * `buildLlmsFullTxt()` (whole corpus) and the MCP tools' `getDoc()`.
+ * Each page starts with a `# title` header; the page's own URL is implied
+ * by the `.md` request itself, so no URL is echoed in the body.
+ *
+ * 3. `createMarkdownHandler()` — a Next.js route-handler factory (mirrors
+ * `createMcpHandler`). Mount it at `app/markdown/[[...slug]]/route.ts` and
+ * have the app's `proxy.ts` rewrite `/.md` URLs onto it (see the
+ * function's own docstring for the mount snippet). Unknown pages return
+ * a plain 404.
+ */
+
+import { unified } from 'unified';
+import remarkParse from 'remark-parse';
+import remarkGfm from 'remark-gfm';
+import remarkDirective from 'remark-directive';
+import remarkMdx from 'remark-mdx';
+import { toMarkdown } from 'mdast-util-to-markdown';
+import { gfmToMarkdown } from 'mdast-util-gfm';
+import { mdxToMarkdown } from 'mdast-util-mdx';
+import { mdxJsxToMarkdown } from 'mdast-util-mdx-jsx';
+import { visit } from 'unist-util-visit';
+import type { Node, Root } from 'mdast';
+import { loadBlogPost, loadChangelogEntries, loadDocPage, loadDocsConfig, stripLeadingH1 } from './content';
+import { findDocPage } from './nav';
+import { loadApiDocument } from './mcp/tools';
+import { renderOperationMarkdown } from './openapi-engine/markdown';
+
+/**
+ * First attribute value that looks like a URL — detected by VALUE, not by
+ * attribute name (so `href`, `url`, `src`, a custom prop, anything). Used to
+ * surface a component's destination as a real markdown link instead of dead
+ * text (e.g. a `` with an `href`).
+ */
+function wrapperUrl(node: { attributes?: unknown[] }): string | undefined {
+ for (const raw of node.attributes ?? []) {
+ const attr = raw as { value?: unknown };
+ if (typeof attr.value !== 'string') continue;
+ const v = attr.value;
+ // anchor, relative path, or absolute URL — not bare text like "unlock"
+ if (/^(#|\/|\.\/|https?:\/\/|mailto:)/.test(v)) return v;
+ }
+ return undefined;
+}
+
+/**
+ * First attribute value that reads as a human label — a space or an
+ * uppercase letter (e.g. `title="No auth required"`, `caption="…"`). Bare
+ * identifiers like `type="info"` or `icon="unlock"` are skipped: they're
+ * variants/decoration, not content. Detected by value, not by name.
+ */
+function label(node: { attributes?: unknown[] }): string | undefined {
+ for (const raw of node.attributes ?? []) {
+ const attr = raw as { value?: unknown };
+ if (typeof attr.value !== 'string') continue;
+ const v = attr.value;
+ if (/^(#|\/|\.\/|https?:\/\/|mailto:)/.test(v)) continue; // it's a URL
+ if (/[A-Z\s]/.test(v) && v.length <= 80) return v;
+ }
+ return undefined;
+}
+
+/** mdast-util-mdx-jsx's own to-markdown handlers, so we can delegate to them. */
+const mdxJsxHandlers = mdxJsxToMarkdown().handlers!;
+const mdxFlowHandler = mdxJsxHandlers.mdxJsxFlowElement!;
+const mdxTextHandler = mdxJsxHandlers.mdxJsxTextElement!;
+
+/**
+ * Pre-stringify transforms that are easier expressed as tree edits than as
+ * toMarkdown handlers:
+ * - drop `mdxjsEsm` (imports/exports are machinery, not content)
+ * - `:::callout` directives → blockquote
+ * - leaf/text directives → their children (or drop when empty)
+ */
+function remarkPlainMarkdown() {
+ return (tree: Root) => {
+ visit(tree, (node, index, parent) => {
+ if (!parent || index === undefined) return;
+ const n = node as unknown as { type: string; children?: Node[] };
+
+ switch (n.type) {
+ case 'mdxjsEsm':
+ (parent.children as unknown[]).splice(index, 1);
+ return;
+ case 'containerDirective': {
+ // `:::info … :::` maps to when rendered; the
+ // closest markdown-native shape is a blockquote.
+ const directive = n as { type: string; name?: unknown; attributes?: unknown };
+ directive.type = 'blockquote';
+ delete directive.name;
+ delete directive.attributes;
+ return;
+ }
+ case 'leafDirective':
+ case 'textDirective': {
+ const children = (n.children ?? []) as Node[];
+ // Replace the directive node with its children (or drop it). Spliced
+ // in place so `visit`'s index stays valid for the remaining siblings.
+ (parent.children as Node[]).splice(index, 1, ...children);
+ return;
+ }
+ }
+ });
+ };
+}
+
+/**
+ * Convert an MDX source string into cleaned, LLM-readable Markdown. See the
+ * module docstring for the component policy. Pure — no IO.
+ */
+export function processMarkdown(source: string): string {
+ // `.parse()` only runs the parsers; the transformer plugins (remarkGfm's
+ // table/task handling, remarkDirective, remarkPlainMarkdown) run in
+ // `.run()` — so parse first, then run the transformers, then stringify.
+ const processor = unified()
+ .use(remarkParse)
+ .use(remarkMdx)
+ .use(remarkGfm)
+ .use(remarkDirective)
+ .use(remarkPlainMarkdown);
+
+ const tree = processor.runSync(processor.parse(source) as Root) as Root;
+
+ const markdown = toMarkdown(tree, {
+ extensions: [gfmToMarkdown(), mdxToMarkdown()],
+ handlers: {
+ mdxJsxFlowElement(node, parent, state, info) {
+ // No children → self-closing data component (e.g. ) — keep its tag, props are the content.
+ if (node.children.length === 0) {
+ return mdxFlowHandler(node, parent, state, info);
+ }
+ // Has children → a wrapper; keep the inner content. If an attribute
+ // carries a URL (by value), surface it as a link; if one carries a
+ // human-readable label (by value), surface it as bold text.
+ const url = wrapperUrl(node);
+ const text = label(node);
+ const head = text ? (url ? `**[${text}](${url})**` : `**${text}**`) : url ? `[${url}](${url})` : '';
+ const body = state.containerFlow(node, info);
+ return head ? `${head}\n\n${body}` : body;
+ },
+ mdxJsxTextElement(node, parent, state, info) {
+ if (node.children.length === 0) {
+ return mdxTextHandler(node, parent, state, info);
+ }
+ const url = wrapperUrl(node);
+ const text = label(node);
+ const head = text ? (url ? `**[${text}](${url})**` : `**${text}**`) : url ? `[${url}](${url})` : '';
+ const body = state.containerPhrasing(node, info);
+ return head ? `${head} ${body}` : body;
+ },
+ },
+ });
+
+ return markdown.trim().replace(/\n{3,}/g, '\n\n') + '\n';
+}
+
+// ── buildMarkdownPage ────────────────────────────────────────────────────────
+
+export interface BuildMarkdownPageOptions {
+ /**
+ * The app's API Reference tab slug, e.g. "api-reference", used to resolve
+ * `/operations/` URLs. Callers compute this from
+ * their own docs.json (see each app's `lib/route.ts` `apiBasePath`) — same
+ * convention as `ai/ask.ts` and `llms-txt.ts`; the framework stays decoupled
+ * from any one app's routing. Defaults to "api-reference".
+ */
+ apiBasePath?: string;
+}
+
+function composePage(title: string, description: string | null, content: string): string {
+ const parts = [`# ${title}`];
+ if (description) parts.push('', description);
+ parts.push('', stripLeadingH1(content).trim());
+ return parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
+}
+
+/**
+ * Resolve a slug to the Markdown for one page. Order of resolution:
+ * API operation → blog post → changelog entry → doc page (the docs nav owns
+ * every other slug; the index page is the empty string). Returns null when
+ * the slug matches nothing.
+ */
+export async function buildMarkdownPage(
+ slug: string,
+ options: BuildMarkdownPageOptions = {},
+): Promise {
+ const config = loadDocsConfig();
+ if (!config) return null;
+
+ const apiBase = (options.apiBasePath ?? 'api-reference').replace(/^\/+|\/+$/g, '');
+ // Next.js serves /index as an alias of the root page; resolve it to the
+ // index slug here so /index.md maps to the index page.
+ const normalizedSlug = slug === 'index' ? '' : slug;
+ const segments = normalizedSlug.split('/').filter(Boolean);
+
+ // API operation — /operations/.
+ if (segments[0] === apiBase && segments[1] === 'operations' && segments.length >= 3) {
+ const operationId = segments.slice(2).join('/');
+ const document = await loadApiDocument();
+ if (!document) return null;
+ const markdown = renderOperationMarkdown(document, { operationId });
+ if (!markdown) return null;
+ const info = document.info as { title?: unknown } | undefined;
+ const title = typeof info?.title === 'string' ? info.title : 'API Reference';
+ return composePage(title, null, markdown);
+ }
+
+ // Blog post — blog/.
+ if (segments[0] === 'blog' && segments.length === 2) {
+ const post = loadBlogPost(segments[1]);
+ if (!post) return null;
+ return composePage(post.title, post.description, processMarkdown(post.content));
+ }
+
+ // Changelog entry — changelog/ (entries render on the single /changelog page).
+ if (segments[0] === 'changelog' && segments.length === 2) {
+ const entry = loadChangelogEntries().find((e) => e.slug === segments[1]);
+ if (!entry) return null;
+ return composePage(entry.title, entry.version, processMarkdown(entry.content));
+ }
+
+ // Doc page — the docs nav owns every other slug ('' = index).
+ const page = findDocPage(config, normalizedSlug);
+ if (!page) return null;
+ const loaded = loadDocPage(page.file);
+ if (!loaded) return null;
+ const description = typeof loaded.data.description === 'string' ? loaded.data.description : null;
+ return composePage(page.title, description, processMarkdown(loaded.content));
+}
+
+// ── createMarkdownHandler ────────────────────────────────────────────────────
+
+export interface CreateMarkdownHandlerOptions extends BuildMarkdownPageOptions {}
+
+/**
+ * Creates a Next.js route-handler-shaped function. Uses Next's own
+ * `[[...slug]]` params — the same mechanism Next uses to route pages — rather
+ * than parsing the URL, so the markdown endpoint and the docs page agree on
+ * which "file" a path resolves to.
+ *
+ * Mount it at `app/markdown/[[...slug]]/route.ts`:
+ *
+ * ```ts
+ * import { createMarkdownHandler } from '@inkform/framework/markdown';
+ * import { apiBasePath, loadDocsConfig } from '@/lib/route';
+ *
+ * export const runtime = 'nodejs';
+ *
+ * const config = loadDocsConfig();
+ * const apiBase = config && apiBasePath(config);
+ * export const GET = createMarkdownHandler({ apiBasePath: apiBase ?? undefined });
+ * ```
+ *
+ * The app's `proxy.ts` rewrites any `/.md` URL onto this route (the
+ * public URL keeps its `.md` suffix), so `/quickstart.md`, `/index.md`, and
+ * `/concepts/pagination.md` map to the same pages as `/quickstart`, `/`, and
+ * `/concepts/pagination`. Unknown pages return a plain 404.
+ */
+export function createMarkdownHandler(options: CreateMarkdownHandlerOptions = {}) {
+ return async (
+ _request: Request,
+ context: { params: Promise<{ slug?: string[] }> },
+ ): Promise => {
+ const { slug = [] } = await context.params;
+ const markdown = await buildMarkdownPage(slug.join('/'), options);
+ if (markdown === null) return new Response('Not Found', { status: 404 });
+ return new Response(markdown, {
+ headers: { 'content-type': 'text/markdown; charset=utf-8' },
+ });
+ };
+}
diff --git a/packages/framework/src/page-actions.tsx b/packages/framework/src/page-actions.tsx
new file mode 100644
index 0000000..2e7d34f
--- /dev/null
+++ b/packages/framework/src/page-actions.tsx
@@ -0,0 +1,355 @@
+'use client';
+
+import * as React from 'react';
+import { AI_TOOLS, buildAiToolAction, safeOrigin, type AiToolAction, type AiToolId } from './ai-tools';
+import { defaultAiToolIcons } from './ai-tool-icons';
+import { Confetti } from './confetti';
+import { CopyGlyph, CheckGlyph, ExternalGlyph, TextGlyph } from './glyphs';
+import { copyText } from './clipboard';
+
+/**
+ * Per-page actions: "Copy as Markdown" and an "Open" menu (view the raw
+ * Markdown, or hand the page to ChatGPT/Claude/Cursor/…).
+ *
+ * These are the page-level companions to the framework's `*.md` endpoint
+ * (see ./markdown.ts): `MarkdownCopyButton` fetches the page's own `.md`
+ * document and puts it on the clipboard, and `ViewOptionsPopover` links to
+ * the same `.md` URL plus the AI tools.
+ *
+ * Both are Client Components and take only serializable props (an `icons`
+ * map of pre-rendered ReactNode per tool, mirroring AiToolMenu — a live
+ * function prop can't cross the Server → Client boundary). Tools without an
+ * entry fall back to a monochrome brand mark per tool (./ai-tool-icons).
+ */
+
+/* ─────────────────────────────────────────────
+ MarkdownCopyButton
+───────────────────────────────────────────── */
+
+// Cache the fetched Markdown per URL so copying the same page twice doesn't
+// refetch. Keyed by the URL string; a module-level map like SearchDialog's
+// pagefindPromise. Only successful responses are cached — a failure leaves
+// the key unset so a later copy can retry.
+const markdownCache = new Map>();
+
+function fetchMarkdown(url: string): Promise {
+ return fetch(url).then(async (res) => {
+ if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
+ return res.text();
+ });
+}
+
+export interface MarkdownCopyButtonProps {
+ /**
+ * URL of this page's Markdown document, e.g. `/quickstart.md`. Fetched and
+ * copied verbatim.
+ */
+ markdownUrl: string;
+ /** Button label. Defaults to "Copy Markdown". */
+ label?: string;
+ /** Pre-rendered icon; falls back to a small built-in glyph. */
+ icon?: React.ReactNode;
+ /** Extra class name on the