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 ( +