From 462fae53a05947336d2e328389c23ff37a1fd6ee Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Aug 2026 18:20:39 +0530 Subject: [PATCH 01/23] feat: serve every page as Markdown at its own URL + .md --- .../app/markdown/[[...slug]]/route.ts | 16 + examples/inkform-docs/proxy.ts | 10 + .../app/markdown/[[...slug]]/route.ts | 16 + examples/markdown-docs/proxy.ts | 10 + .../app/markdown/[[...slug]]/route.ts | 16 + examples/pokeapi-docs/proxy.ts | 10 + packages/framework/package.json | 1 + packages/framework/src/markdown.test.ts | 169 ++++++++++ packages/framework/src/markdown.ts | 292 ++++++++++++++++++ .../canopy/app/markdown/[[...slug]]/route.ts | 16 + templates/canopy/proxy.ts | 10 + .../galley/app/markdown/[[...slug]]/route.ts | 16 + templates/galley/proxy.ts | 10 + .../shadcn/app/markdown/[[...slug]]/route.ts | 16 + templates/shadcn/proxy.ts | 10 + 15 files changed, 618 insertions(+) create mode 100644 examples/inkform-docs/app/markdown/[[...slug]]/route.ts create mode 100644 examples/markdown-docs/app/markdown/[[...slug]]/route.ts create mode 100644 examples/pokeapi-docs/app/markdown/[[...slug]]/route.ts create mode 100644 packages/framework/src/markdown.test.ts create mode 100644 packages/framework/src/markdown.ts create mode 100644 templates/canopy/app/markdown/[[...slug]]/route.ts create mode 100644 templates/galley/app/markdown/[[...slug]]/route.ts create mode 100644 templates/shadcn/app/markdown/[[...slug]]/route.ts diff --git a/examples/inkform-docs/app/markdown/[[...slug]]/route.ts b/examples/inkform-docs/app/markdown/[[...slug]]/route.ts new file mode 100644 index 0000000..4eef03c --- /dev/null +++ b/examples/inkform-docs/app/markdown/[[...slug]]/route.ts @@ -0,0 +1,16 @@ +import { createMarkdownHandler } from '@inkform/framework/markdown'; +import { apiBasePath, loadDocsConfig } from '@/lib/route'; + +export const runtime = 'nodejs'; + +/** + * GET .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/markdown/[[...slug]]/route.ts b/examples/markdown-docs/app/markdown/[[...slug]]/route.ts new file mode 100644 index 0000000..f890029 --- /dev/null +++ b/examples/markdown-docs/app/markdown/[[...slug]]/route.ts @@ -0,0 +1,16 @@ +import { createMarkdownHandler } from '@inkform/framework/markdown'; +import { apiBasePath, loadDocsConfig } from '@/lib/route'; + +export const runtime = 'nodejs'; + +/** + * GET .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/markdown/[[...slug]]/route.ts b/examples/pokeapi-docs/app/markdown/[[...slug]]/route.ts new file mode 100644 index 0000000..f890029 --- /dev/null +++ b/examples/pokeapi-docs/app/markdown/[[...slug]]/route.ts @@ -0,0 +1,16 @@ +import { createMarkdownHandler } from '@inkform/framework/markdown'; +import { apiBasePath, loadDocsConfig } from '@/lib/route'; + +export const runtime = 'nodejs'; + +/** + * GET .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/packages/framework/package.json b/packages/framework/package.json index 4d27b2c..274b544 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", diff --git a/packages/framework/src/markdown.test.ts b/packages/framework/src/markdown.test.ts new file mode 100644 index 0000000..9c6d8c3 --- /dev/null +++ b/packages/framework/src/markdown.test.ts @@ -0,0 +1,169 @@ +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(''); + expect(md).toContain('Get a Pokémon'); + }); + + it('collapses layout wrappers to their inner content', () => { + 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 + URL header', async () => { + const md = await buildMarkdownPage('quickstart'); + expect(md).toMatch(/^# Quickstart/); + expect(md).toContain('URL: /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(/^# /); + expect(md).toContain('URL: /'); + }); + + 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).toContain('URL: /blog/building-a-pokedex-with-nextjs'); + }); + + it('returns a changelog entry under changelog/', async () => { + const md = await buildMarkdownPage('changelog/v1-0'); + expect(md).toContain('URL: /changelog/v1-0'); + 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); + const text = await res.text(); + expect(text).toMatch(/^# /); + expect(text).toContain('URL: /'); + }); + + 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..952840a --- /dev/null +++ b/packages/framework/src/markdown.ts @@ -0,0 +1,292 @@ +/** + * @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 (nothing is dropped unless it's pure machinery): + * - `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 ``). + * - Pure layout wrappers (`Tabs`, `Tab`, `CodeGroup`, `Columns`) → + * children-only: the tag collapses, the inner content stays. + * - Every other MDX component → the JSX tag is KEPT with its attributes, + * with children nested inside. So ``, + * ``, and + * `Get a Pokémon` all survive + * verbatim — prop-carried data is never lost, and `{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 gets a `# title` + `URL:` header so agents know where the + * content came from. + * + * 3. `createMarkdownHandler()` — a Next.js route-handler factory (mirrors + * `createMcpHandler`). Mount it at `app/markdown/[[...slug]]/route.ts`: + * + * ```ts + * // app/markdown/[[...slug]]/route.ts + * import { createMarkdownHandler } from '@inkform/framework/markdown'; + * import { apiBasePath, loadDocsConfig } from '@/lib/route'; + * + * export const runtime = 'nodejs'; + * export const GET = createMarkdownHandler({ + * apiBasePath: (() => { + * const config = loadDocsConfig(); + * return config && apiBasePath(config) ? apiBasePath(config) : undefined; + * })(), + * }); + * ``` + * + * The handler reads the slug from Next's `[[...slug]]` params — the same + * resolution the docs page uses. The app's `proxy.ts` rewrites any + * `/.md` URL onto this route (public URL unchanged), so + * `/quickstart.md` and `/index.md` map to the same pages as `/quickstart` + * and `/`. Unknown pages return 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 } from './content'; +import { findDocPage } from './nav'; +import { loadApiDocument } from './mcp/tools'; +import { renderOperationMarkdown } from './openapi-engine/markdown'; + +/** + * Pure layout wrappers — the tag carries no content of its own, so only the + * inner content survives. Everything else keeps its JSX tag (props are data; + * see the module docstring). + */ +const LAYOUT_WRAPPERS = new Set(['Tabs', 'Tab', 'CodeGroup', 'Columns']); + +/** 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; + // Replace the directive node with its children (or drop it entirely). + (parent.children as unknown[]) = [ + ...(parent.children as unknown[]).slice(0, index), + ...(children ?? []), + ...(parent.children as unknown[]).slice(index + 1), + ]; + 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) { + const name = typeof node.name === 'string' ? node.name : ''; + if (LAYOUT_WRAPPERS.has(name)) { + if (node.children.length === 0) return ''; + return state.containerFlow(node, info); + } + return mdxFlowHandler(node, parent, state, info); + }, + mdxJsxTextElement(node, parent, state, info) { + const name = typeof node.name === 'string' ? node.name : ''; + if (LAYOUT_WRAPPERS.has(name)) { + if (node.children.length === 0) return ''; + return state.containerPhrasing(node, info); + } + return mdxTextHandler(node, parent, state, info); + }, + }, + }); + + 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, url: string, description: string | null, content: string): string { + const parts = [`# ${title}`, '', `URL: ${url}`]; + if (description) parts.push('', description); + parts.push('', 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, `/${apiBase}/operations/${operationId}`, 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, `/blog/${post.slug}`, 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, `/changelog/${entry.slug}`, 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, `/${normalizedSlug}`, 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. + * + * ```ts + * // app/[[...slug]].md/route.ts + * import { createMarkdownHandler } from '@inkform/framework/markdown'; + * import { apiBasePath, loadDocsConfig } from '@/lib/route'; + * + * export const runtime = 'nodejs'; + * export const GET = createMarkdownHandler({ + * apiBasePath: (() => { + * const config = loadDocsConfig(); + * return config && apiBasePath(config) ? apiBasePath(config) : undefined; + * })(), + * }); + * ``` + * + * Mounted at `app/[[...slug]].md/route.ts`, the URL is `/quickstart.md`, + * `/index.md`, `/concepts/pagination.md`, etc. — the page's own URL with a + * `.md` extension, resolved through the same `[[...slug]]` params the docs + * page uses. 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/templates/canopy/app/markdown/[[...slug]]/route.ts b/templates/canopy/app/markdown/[[...slug]]/route.ts new file mode 100644 index 0000000..f890029 --- /dev/null +++ b/templates/canopy/app/markdown/[[...slug]]/route.ts @@ -0,0 +1,16 @@ +import { createMarkdownHandler } from '@inkform/framework/markdown'; +import { apiBasePath, loadDocsConfig } from '@/lib/route'; + +export const runtime = 'nodejs'; + +/** + * GET .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/templates/canopy/proxy.ts b/templates/canopy/proxy.ts index a0fd617..59aae83 100644 --- a/templates/canopy/proxy.ts +++ b/templates/canopy/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/templates/galley/app/markdown/[[...slug]]/route.ts b/templates/galley/app/markdown/[[...slug]]/route.ts new file mode 100644 index 0000000..f890029 --- /dev/null +++ b/templates/galley/app/markdown/[[...slug]]/route.ts @@ -0,0 +1,16 @@ +import { createMarkdownHandler } from '@inkform/framework/markdown'; +import { apiBasePath, loadDocsConfig } from '@/lib/route'; + +export const runtime = 'nodejs'; + +/** + * GET .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/templates/galley/proxy.ts b/templates/galley/proxy.ts index a0fd617..59aae83 100644 --- a/templates/galley/proxy.ts +++ b/templates/galley/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/templates/shadcn/app/markdown/[[...slug]]/route.ts b/templates/shadcn/app/markdown/[[...slug]]/route.ts new file mode 100644 index 0000000..f890029 --- /dev/null +++ b/templates/shadcn/app/markdown/[[...slug]]/route.ts @@ -0,0 +1,16 @@ +import { createMarkdownHandler } from '@inkform/framework/markdown'; +import { apiBasePath, loadDocsConfig } from '@/lib/route'; + +export const runtime = 'nodejs'; + +/** + * GET .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/templates/shadcn/proxy.ts b/templates/shadcn/proxy.ts index a0fd617..59aae83 100644 --- a/templates/shadcn/proxy.ts +++ b/templates/shadcn/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, From 8073156e7443362c3cdcde07de43d447dafde038 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Aug 2026 21:22:35 +0530 Subject: [PATCH 02/23] feat: add Copy Markdown and Open page actions --- .../inkform-docs/app/[[...slug]]/page.tsx | 2 + .../markdown-docs/app/[[...slug]]/page.tsx | 2 + .../pokeapi-docs/app/[[...slug]]/page.tsx | 2 + packages/framework/package.json | 1 + packages/framework/src/ai-tool-menu.tsx | 16 +- packages/framework/src/page-actions.tsx | 338 ++++++++++++++++++ packages/framework/src/styles/widgets.css | 128 +++++++ templates/canopy/app/[[...slug]]/page.tsx | 2 + templates/galley/app/[[...slug]]/page.tsx | 2 + templates/shadcn/app/[[...slug]]/page.tsx | 2 + 10 files changed, 487 insertions(+), 8 deletions(-) create mode 100644 packages/framework/src/page-actions.tsx diff --git a/examples/inkform-docs/app/[[...slug]]/page.tsx b/examples/inkform-docs/app/[[...slug]]/page.tsx index 77d58bc..de0fa0e 100644 --- a/examples/inkform-docs/app/[[...slug]]/page.tsx +++ b/examples/inkform-docs/app/[[...slug]]/page.tsx @@ -1,6 +1,7 @@ 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 { siteMdxComponents } from '@/mdx-components'; @@ -57,6 +58,7 @@ export default async function Page({ params }: { params: Promise<{ slug?: string toc={headings.length > 0 ? : undefined} hideToc={headings.length === 0} > + 0 ? : undefined} hideToc={headings.length === 0} > + 0 ? : undefined} hideToc={headings.length === 0} > + — `q` pre-fills (but // doesn't auto-submit) the composer. `hints=search` is undocumented but // present in Sequoia's real link; left in since it's what was observed @@ -115,16 +115,16 @@ function chatGptUrl(pageUrl: string): string { return `https://chat.openai.com/?hints=search&q=${encodeURIComponent(buildPrompt(pageUrl))}`; } -function claudeUrl(pageUrl: string): string { +export function claudeUrl(pageUrl: string): string { // https://claude.ai/new?q= — same pre-fill convention as ChatGPT. return `https://claude.ai/new?q=${encodeURIComponent(buildPrompt(pageUrl))}`; } -function perplexityUrl(pageUrl: string): string { +export function perplexityUrl(pageUrl: string): string { return `https://www.perplexity.ai/search?q=${encodeURIComponent(buildPrompt(pageUrl))}`; } -function grokUrl(pageUrl: string): string { +export function grokUrl(pageUrl: string): string { // grok.com's `q` param isn't publicly documented anywhere findable, but it // demonstrably pre-fills the composer on Sequoia's real production site // (confirmed the same way as chatGptUrl above) — real and working, just @@ -132,7 +132,7 @@ function grokUrl(pageUrl: string): string { return `https://grok.com/?q=${encodeURIComponent(buildPrompt(pageUrl))}`; } -function cursorDeeplink(siteName: string, mcpUrl: string): string { +export function cursorDeeplink(siteName: string, mcpUrl: string): string { // Cursor's documented one-click MCP install deep link: // cursor://anysphere.cursor-deeplink/mcp/install?name=&config= // This deliberately does NOT open the doc page — it registers THIS SITE'S @@ -145,7 +145,7 @@ function cursorDeeplink(siteName: string, mcpUrl: string): string { return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(siteName)}&config=${config}`; } -function vscodeDeeplink(siteName: string, mcpUrl: string): string { +export function vscodeDeeplink(siteName: string, mcpUrl: string): string { // VS Code's documented MCP install URI: vscode:mcp/install? // — note the query segment IS the encoded JSON, not key=value pairs. Same // "install this site's MCP server" idea as cursorDeeplink above. diff --git a/packages/framework/src/page-actions.tsx b/packages/framework/src/page-actions.tsx new file mode 100644 index 0000000..1bb3421 --- /dev/null +++ b/packages/framework/src/page-actions.tsx @@ -0,0 +1,338 @@ +'use client'; + +import * as React from 'react'; +import { + chatGptUrl, + claudeUrl, + cursorDeeplink, + grokUrl, + perplexityUrl, + safeOrigin, + vscodeDeeplink, +} from './ai-tool-menu'; + +/** + * 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). + */ + +/* ───────────────────────────────────────────── + 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. +const markdownCache = new Map>(); + +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 + ); +} + +/* ───────────────────────────────────────────── + ViewOptionsPopover +───────────────────────────────────────────── */ + +export interface ViewOptionsPopoverProps { + /** + * URL of this page's Markdown document, e.g. `/quickstart.md`. Renders the + * "View as Markdown" entry. Omit to hide it. + */ + markdownUrl?: string; + /** Absolute URL of the page itself — used to build the AI-tool prompt links. */ + pageUrl?: string; + /** Source file URL on GitHub, e.g. `https://github.com/…/blob/…/quickstart.mdx`. */ + githubUrl?: string; + /** + * Absolute URL of this site's own MCP endpoint (mount one via + * `createMcpHandler()` from '@inkform/framework/mcp'). Defaults to + * `${origin}/api/mcp`. Pass `null` to omit the Cursor/VS Code entries. + */ + mcpUrl?: string | null; + /** Shown to Cursor/VS Code as the installed MCP server's label. Defaults to 'Docs'. */ + siteName?: string; + /** Trigger button label. Defaults to "Open". */ + triggerLabel?: string; + /** + * Pre-rendered icon per tool (e.g. Lucide elements), keyed by tool id. + * Mirrors AiToolMenu's `icons` prop; falls back to built-in glyphs. + */ + icons?: Partial>; + /** Extra class name on the wrapper. */ + className?: string; +} + +function ExternalGlyph() { + return ( + + ); +} + +function TextGlyph() { + return ( + + ); +} + +/** + * An "Open" dropdown with a "View as Markdown" link (the page's own `.md` + * document) and "hand this page to an AI tool" links — ChatGPT, Claude, + * Cursor, VS Code, Perplexity, Grok — reusing the same URL builders as + * AiToolMenu. Closes on Escape or an outside click. + */ +export function ViewOptionsPopover({ + markdownUrl, + pageUrl, + githubUrl, + mcpUrl, + siteName = 'Docs', + triggerLabel = 'Open', + icons, + className, +}: ViewOptionsPopoverProps) { + const [open, setOpen] = React.useState(false); + const rootRef = React.useRef(null); + + // Page URL resolves client-side (SSG pages have no request context), same + // as AiToolMenu — `pageUrl` only prevents a brief blank before mount. + const [liveUrl, setLiveUrl] = React.useState(pageUrl); + const [liveOrigin, setLiveOrigin] = React.useState(() => + pageUrl ? safeOrigin(pageUrl) : undefined, + ); + + React.useEffect(() => { + setLiveUrl(window.location.href); + setLiveOrigin(window.location.origin); + }, []); + + React.useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } + function onPointer(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener('keydown', onKey); + document.addEventListener('mousedown', onPointer); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('mousedown', onPointer); + }; + }, [open]); + + const resolvedUrl = liveUrl ?? ''; + const resolvedMcpUrl = mcpUrl === null ? null : (mcpUrl ?? (liveOrigin ? `${liveOrigin}/api/mcp` : null)); + + const items: { id: string; label: string; href: string }[] = []; + if (githubUrl) items.push({ id: 'github', label: 'Open in GitHub', href: githubUrl }); + if (markdownUrl) items.push({ id: 'markdown', label: 'View as Markdown', href: markdownUrl }); + if (resolvedUrl) { + items.push({ id: 'chatgpt', label: 'Open in ChatGPT', href: chatGptUrl(resolvedUrl) }); + items.push({ id: 'claude', label: 'Open in Claude', href: claudeUrl(resolvedUrl) }); + } + if (resolvedMcpUrl) { + items.push({ id: 'cursor', label: 'Connect to Cursor', href: cursorDeeplink(siteName, resolvedMcpUrl) }); + items.push({ id: 'vscode', label: 'Connect to VS Code', href: vscodeDeeplink(siteName, resolvedMcpUrl) }); + } + if (resolvedUrl) { + items.push({ id: 'perplexity', label: 'Open in Perplexity', href: perplexityUrl(resolvedUrl) }); + items.push({ id: 'grok', label: 'Open in Grok', href: grokUrl(resolvedUrl) }); + } + + function icon(id: string): React.ReactNode { + if (icons?.[id]) return icons[id] as React.ReactNode; + if (id === 'markdown') return ; + return ; + } + + return ( +
+ + + {open ? ( +
+ {items.map((item) => ( + + + {icon(item.id)} + + {item.label} + + ))} +
+ ) : null} +
+ ); +} + +/* ───────────────────────────────────────────── + PageActions — both buttons in one row +───────────────────────────────────────────── */ + +export interface PageActionsProps extends ViewOptionsPopoverProps { + /** URL of this page's Markdown document, e.g. `/quickstart.md`. */ + markdownUrl: string; + /** Label for the copy button. Defaults to "Copy Markdown". */ + copyLabel?: string; + /** Pre-rendered copy icon. */ + copyIcon?: React.ReactNode; +} + +/** + * The two page-level actions side by side: Copy as Markdown + Open. + * + * ```tsx + * + * ``` + */ +export function PageActions(props: PageActionsProps) { + const { markdownUrl, copyLabel, copyIcon, className, ...popoverProps } = props; + return ( +
+ + +
+ ); +} diff --git a/packages/framework/src/styles/widgets.css b/packages/framework/src/styles/widgets.css index 125c8f4..e232bcf 100644 --- a/packages/framework/src/styles/widgets.css +++ b/packages/framework/src/styles/widgets.css @@ -850,3 +850,131 @@ font-style: normal; user-select: none; } + + +/* --------------------------------------------------------------- + PAGE ACTIONS — "Copy as Markdown" + "Open" menu + (src/page-actions.tsx). Small secondary buttons in a row; the Open + menu is an absolutely-positioned card anchored to its trigger. +--------------------------------------------------------------- */ + +.fw-page-actions { + display: inline-flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0.75rem 0 1.5rem; +} + +.fw-page-action { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.35rem 0.75rem; + background: var(--fw-card); + border: 1px solid var(--fw-border); + border-radius: var(--fw-radius-sm); + color: var(--fw-muted); + font: inherit; + font-size: 0.8125rem; + line-height: 1; + cursor: pointer; + transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease; +} + +.fw-page-action:hover { + border-color: var(--fw-border-strong); + color: var(--fw-fg); + background: var(--fw-card-hover); +} + +.fw-page-action:focus-visible { + outline: none; + box-shadow: var(--fw-ring); +} + +.fw-page-action:disabled { + opacity: 0.6; + cursor: default; +} + +.fw-page-action--copied, +.fw-page-action--copied .fw-page-action-icon { + color: var(--fw-primary); +} + +.fw-page-action-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 15px; + height: 15px; +} + +.fw-page-action-label { + white-space: nowrap; +} + +.fw-page-action-chevron { + transition: transform 0.12s ease; +} + +.fw-page-action--open .fw-page-action-chevron { + transform: rotate(180deg); +} + +.fw-page-action-group { + position: relative; + display: inline-flex; +} + +.fw-page-action-menu { + position: absolute; + top: calc(100% + 0.375rem); + left: 0; + min-width: 15rem; + z-index: 50; + padding: 0.375rem; + background: var(--fw-card); + border: 1px solid var(--fw-border); + border-radius: var(--fw-radius); + box-shadow: var(--fw-shadow-lg); + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.fw-page-action-menu-item { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.375rem 0.5rem; + border-radius: var(--fw-radius-sm); + color: var(--fw-muted); + font-size: 0.8125rem; + text-decoration: none; + white-space: nowrap; +} + +.fw-page-action-menu-item:hover { + background: var(--fw-card-hover); + color: var(--fw-fg); +} + +.fw-page-action-menu-item:focus-visible { + outline: none; + box-shadow: var(--fw-ring); +} + +.fw-page-action-menu-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 15px; + height: 15px; +} + +.fw-page-action-menu-item:hover .fw-page-action-menu-icon { + color: var(--fw-primary); +} diff --git a/templates/canopy/app/[[...slug]]/page.tsx b/templates/canopy/app/[[...slug]]/page.tsx index 77d58bc..de0fa0e 100644 --- a/templates/canopy/app/[[...slug]]/page.tsx +++ b/templates/canopy/app/[[...slug]]/page.tsx @@ -1,6 +1,7 @@ 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 { siteMdxComponents } from '@/mdx-components'; @@ -57,6 +58,7 @@ export default async function Page({ params }: { params: Promise<{ slug?: string toc={headings.length > 0 ? : undefined} hideToc={headings.length === 0} > + 0 ? : undefined} hideToc={headings.length === 0} > + 0 ? : undefined} hideToc={headings.length === 0} > + Date: Wed, 5 Aug 2026 22:25:20 +0530 Subject: [PATCH 03/23] feat: structural MDX-to-markdown with URL/label surfacing --- packages/framework/src/markdown.test.ts | 16 ++-- packages/framework/src/markdown.ts | 107 +++++++++++++++------- packages/framework/src/styles/widgets.css | 2 +- 3 files changed, 83 insertions(+), 42 deletions(-) diff --git a/packages/framework/src/markdown.test.ts b/packages/framework/src/markdown.test.ts index 9c6d8c3..069de26 100644 --- a/packages/framework/src/markdown.test.ts +++ b/packages/framework/src/markdown.test.ts @@ -32,7 +32,9 @@ Some text. `, ); expect(md).toContain(''); - expect(md).toContain('Get a Pokémon'); + // ApiLink HAS children, so it's treated as a wrapper — its text survives. + expect(md).toContain('Get a Pokémon'); + expect(md).not.toContain(' { @@ -81,17 +83,15 @@ describe('buildMarkdownPage (real pokeapi-docs content)', () => { delete process.env.DOCS_CONTENT_ROOT; }); - it('returns a doc page with a title + URL header', async () => { + it('returns a doc page with a title header', async () => { const md = await buildMarkdownPage('quickstart'); expect(md).toMatch(/^# Quickstart/); - expect(md).toContain('URL: /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(/^# /); - expect(md).toContain('URL: /'); }); it('returns an API operation under /operations/', async () => { @@ -103,12 +103,12 @@ describe('buildMarkdownPage (real pokeapi-docs content)', () => { it('returns a blog post under blog/', async () => { const md = await buildMarkdownPage('blog/building-a-pokedex-with-nextjs'); - expect(md).toContain('URL: /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).toContain('URL: /changelog/v1-0'); + expect(md).toMatch(/^# /); expect(md).toContain('First public version of these docs'); }); @@ -148,9 +148,7 @@ describe('createMarkdownHandler', () => { 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); - const text = await res.text(); - expect(text).toMatch(/^# /); - expect(text).toContain('URL: /'); + expect(await res.text()).toMatch(/^# /); }); it('serves an operation under /operations/', async () => { diff --git a/packages/framework/src/markdown.ts b/packages/framework/src/markdown.ts index 952840a..1921faf 100644 --- a/packages/framework/src/markdown.ts +++ b/packages/framework/src/markdown.ts @@ -16,27 +16,29 @@ * resulting mdast via `mdast-util-to-markdown` — deliberately NOT a regex * strip, which would silently lose data-bearing components. * - * Component policy (nothing is dropped unless it's pure machinery): + * 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 ``). - * - Pure layout wrappers (`Tabs`, `Tab`, `CodeGroup`, `Columns`) → - * children-only: the tag collapses, the inner content stays. - * - Every other MDX component → the JSX tag is KEPT with its attributes, - * with children nested inside. So ``, - * ``, and - * `Get a Pokémon` all survive - * verbatim — prop-carried data is never lost, and `{expr}` expressions - * are left as-is (unresolvable without executing the component). + * - 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 gets a `# title` + `URL:` header so agents know where the - * content came from. + * 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`: @@ -79,11 +81,38 @@ import { loadApiDocument } from './mcp/tools'; import { renderOperationMarkdown } from './openapi-engine/markdown'; /** - * Pure layout wrappers — the tag carries no content of its own, so only the - * inner content survives. Everything else keeps its JSX tag (props are data; - * see the module docstring). + * 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`). */ -const LAYOUT_WRAPPERS = new Set(['Tabs', 'Tab', 'CodeGroup', 'Columns']); +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!; @@ -153,20 +182,29 @@ export function processMarkdown(source: string): string { extensions: [gfmToMarkdown(), mdxToMarkdown()], handlers: { mdxJsxFlowElement(node, parent, state, info) { - const name = typeof node.name === 'string' ? node.name : ''; - if (LAYOUT_WRAPPERS.has(name)) { - if (node.children.length === 0) return ''; - return state.containerFlow(node, 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); } - 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) { - const name = typeof node.name === 'string' ? node.name : ''; - if (LAYOUT_WRAPPERS.has(name)) { - if (node.children.length === 0) return ''; - return state.containerPhrasing(node, info); + if (node.children.length === 0) { + return mdxTextHandler(node, parent, state, info); } - 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; }, }, }); @@ -187,10 +225,15 @@ export interface BuildMarkdownPageOptions { apiBasePath?: string; } -function composePage(title: string, url: string, description: string | null, content: string): string { - const parts = [`# ${title}`, '', `URL: ${url}`]; +/** Strip a leading `# H1` from the body — composePage already emits the title. */ +function stripLeadingH1(content: string): string { + return content.replace(/^#\s+.+\n+/, '').trim(); +} + +function composePage(title: string, description: string | null, content: string): string { + const parts = [`# ${title}`]; if (description) parts.push('', description); - parts.push('', content.trim()); + parts.push('', stripLeadingH1(content)); return parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'; } @@ -222,21 +265,21 @@ export async function buildMarkdownPage( 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, `/${apiBase}/operations/${operationId}`, null, markdown); + 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, `/blog/${post.slug}`, post.description, processMarkdown(post.content)); + 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, `/changelog/${entry.slug}`, entry.version, processMarkdown(entry.content)); + return composePage(entry.title, entry.version, processMarkdown(entry.content)); } // Doc page — the docs nav owns every other slug ('' = index). @@ -245,7 +288,7 @@ export async function buildMarkdownPage( const loaded = loadDocPage(page.file); if (!loaded) return null; const description = typeof loaded.data.description === 'string' ? loaded.data.description : null; - return composePage(page.title, `/${normalizedSlug}`, description, processMarkdown(loaded.content)); + return composePage(page.title, description, processMarkdown(loaded.content)); } // ── createMarkdownHandler ──────────────────────────────────────────────────── diff --git a/packages/framework/src/styles/widgets.css b/packages/framework/src/styles/widgets.css index e232bcf..f1f6ff0 100644 --- a/packages/framework/src/styles/widgets.css +++ b/packages/framework/src/styles/widgets.css @@ -853,7 +853,7 @@ /* --------------------------------------------------------------- - PAGE ACTIONS — "Copy as Markdown" + "Open" menu + PAGE ACTIONS -- "Copy as Markdown" + "Open" menu (src/page-actions.tsx). Small secondary buttons in a row; the Open menu is an absolutely-positioned card anchored to its trigger. --------------------------------------------------------------- */ From 76ccba461cc47e6cb642005b9f0accf171a26732 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Aug 2026 22:32:43 +0530 Subject: [PATCH 04/23] feat: render page title above copy/open actions --- examples/inkform-docs/app/[[...slug]]/page.tsx | 9 ++++++--- examples/markdown-docs/app/[[...slug]]/page.tsx | 9 ++++++--- examples/pokeapi-docs/app/[[...slug]]/page.tsx | 9 ++++++--- packages/framework/src/content.ts | 5 +++++ packages/framework/src/markdown.ts | 9 ++------- packages/framework/src/page-actions.tsx | 16 +++++++++++++--- packages/framework/src/styles/widgets.css | 17 ++++++++++++++++- templates/canopy/app/[[...slug]]/page.tsx | 9 ++++++--- templates/galley/app/[[...slug]]/page.tsx | 9 ++++++--- templates/shadcn/app/[[...slug]]/page.tsx | 9 ++++++--- 10 files changed, 72 insertions(+), 29 deletions(-) diff --git a/examples/inkform-docs/app/[[...slug]]/page.tsx b/examples/inkform-docs/app/[[...slug]]/page.tsx index de0fa0e..6d4e98d 100644 --- a/examples/inkform-docs/app/[[...slug]]/page.tsx +++ b/examples/inkform-docs/app/[[...slug]]/page.tsx @@ -3,7 +3,7 @@ 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'; @@ -58,8 +58,11 @@ export default async function Page({ params }: { params: Promise<{ slug?: string toc={headings.length > 0 ? : undefined} hideToc={headings.length === 0} > - - + + 0 ? : undefined} hideToc={headings.length === 0} > - - + + 0 ? : undefined} hideToc={headings.length === 0} > - - + + { 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/markdown.ts b/packages/framework/src/markdown.ts index 1921faf..2883113 100644 --- a/packages/framework/src/markdown.ts +++ b/packages/framework/src/markdown.ts @@ -75,7 +75,7 @@ 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 } from './content'; +import { loadBlogPost, loadChangelogEntries, loadDocPage, loadDocsConfig, stripLeadingH1 } from './content'; import { findDocPage } from './nav'; import { loadApiDocument } from './mcp/tools'; import { renderOperationMarkdown } from './openapi-engine/markdown'; @@ -225,15 +225,10 @@ export interface BuildMarkdownPageOptions { apiBasePath?: string; } -/** Strip a leading `# H1` from the body — composePage already emits the title. */ -function stripLeadingH1(content: string): string { - return content.replace(/^#\s+.+\n+/, '').trim(); -} - function composePage(title: string, description: string | null, content: string): string { const parts = [`# ${title}`]; if (description) parts.push('', description); - parts.push('', stripLeadingH1(content)); + parts.push('', stripLeadingH1(content).trim()); return parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'; } diff --git a/packages/framework/src/page-actions.tsx b/packages/framework/src/page-actions.tsx index 1bb3421..c0bd0b0 100644 --- a/packages/framework/src/page-actions.tsx +++ b/packages/framework/src/page-actions.tsx @@ -315,6 +315,12 @@ export interface PageActionsProps extends ViewOptionsPopoverProps { copyLabel?: string; /** Pre-rendered copy icon. */ copyIcon?: React.ReactNode; + /** + * Page title, rendered as the H1 above the buttons. The page's own MDX + * normally carries the `# H1` — pass the title here AND strip the leading + * H1 from the MDX source to avoid a duplicate. + */ + title?: string; } /** @@ -322,17 +328,21 @@ export interface PageActionsProps extends ViewOptionsPopoverProps { * * ```tsx * * ``` */ export function PageActions(props: PageActionsProps) { - const { markdownUrl, copyLabel, copyIcon, className, ...popoverProps } = props; + const { title, markdownUrl, copyLabel, copyIcon, className, ...popoverProps } = props; return (
- - + {title ?

{title}

: null} +
+ + +
); } diff --git a/packages/framework/src/styles/widgets.css b/packages/framework/src/styles/widgets.css index f1f6ff0..36f3325 100644 --- a/packages/framework/src/styles/widgets.css +++ b/packages/framework/src/styles/widgets.css @@ -859,10 +859,25 @@ --------------------------------------------------------------- */ .fw-page-actions { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 0.75rem 0 1.5rem; +} + +.fw-page-title { + margin: 0; + font-family: var(--fw-font-heading); + font-size: 1.75em; + font-weight: 600; + line-height: 1.3; + color: var(--fw-fg); +} + +.fw-page-action-row { display: inline-flex; flex-wrap: wrap; gap: 0.5rem; - margin: 0.75rem 0 1.5rem; } .fw-page-action { diff --git a/templates/canopy/app/[[...slug]]/page.tsx b/templates/canopy/app/[[...slug]]/page.tsx index de0fa0e..6d4e98d 100644 --- a/templates/canopy/app/[[...slug]]/page.tsx +++ b/templates/canopy/app/[[...slug]]/page.tsx @@ -3,7 +3,7 @@ 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'; @@ -58,8 +58,11 @@ export default async function Page({ params }: { params: Promise<{ slug?: string toc={headings.length > 0 ? : undefined} hideToc={headings.length === 0} > - - + + 0 ? : undefined} hideToc={headings.length === 0} > - - + + 0 ? : undefined} hideToc={headings.length === 0} > - - + + Date: Wed, 5 Aug 2026 22:39:21 +0530 Subject: [PATCH 05/23] fix: use prompt param for ChatGPT share link --- packages/framework/src/ai-tool-menu.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/framework/src/ai-tool-menu.tsx b/packages/framework/src/ai-tool-menu.tsx index 735b416..9c52642 100644 --- a/packages/framework/src/ai-tool-menu.tsx +++ b/packages/framework/src/ai-tool-menu.tsx @@ -108,11 +108,9 @@ export function safeOrigin(url: string): string | undefined { } export function chatGptUrl(pageUrl: string): string { - // https://chat.openai.com/?hints=search&q= — `q` pre-fills (but - // doesn't auto-submit) the composer. `hints=search` is undocumented but - // present in Sequoia's real link; left in since it's what was observed - // actually shipping, not a guess. - return `https://chat.openai.com/?hints=search&q=${encodeURIComponent(buildPrompt(pageUrl))}`; + // https://chatgpt.com/?hints=search&prompt= — `prompt` pre-fills the + // composer; `hints=search` makes it search-style. Uses `prompt` (not `q`). + return `https://chatgpt.com/?hints=search&prompt=${encodeURIComponent(buildPrompt(pageUrl))}`; } export function claudeUrl(pageUrl: string): string { From 874e6c05f4820cea22bd82edf2dd022c065bf79b Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Aug 2026 22:45:09 +0530 Subject: [PATCH 06/23] refactor: consolidate AI tool links into a data registry --- packages/framework/src/ai-tool-menu.tsx | 109 ++++-------------------- packages/framework/src/ai-tools.ts | 106 +++++++++++++++++++++++ packages/framework/src/page-actions.tsx | 30 +++---- 3 files changed, 133 insertions(+), 112 deletions(-) create mode 100644 packages/framework/src/ai-tools.ts diff --git a/packages/framework/src/ai-tool-menu.tsx b/packages/framework/src/ai-tool-menu.tsx index 9c52642..6d1e541 100644 --- a/packages/framework/src/ai-tool-menu.tsx +++ b/packages/framework/src/ai-tool-menu.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; +import { AI_TOOLS, buildAiToolHref, buildPrompt, safeOrigin, type AiToolId } from './ai-tools'; /** * AiToolMenu — a right-rail list of "hand this page to an AI tool" actions: @@ -8,10 +9,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 @@ -29,7 +28,8 @@ import * as React from 'react'; Types ───────────────────────────────────────────── */ -export type AiToolId = 'copy' | 'chatgpt' | 'claude' | 'cursor' | 'vscode' | 'perplexity' | 'grok'; +export type { AiToolId }; +export { buildPrompt }; export interface AiToolMenuProps { /** @@ -79,77 +79,6 @@ export interface AiToolMenuProps { className?: string; } -/* ───────────────────────────────────────────── - Link builders - - Verified 2026-07 against sequoia.mintlify.site's own production build — - intercepted `window.open()` there rather than guessing, since its actions - are onClick handlers, not plain . Findings behind each comment. -───────────────────────────────────────────── */ - -function buildPrompt(pageUrl: string): string { - return `Read ${pageUrl} and help me understand it`; -} - -/** Unicode-safe base64 (btoa() alone only handles Latin1) — guards a siteName with non-ASCII characters. */ -export 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; - } -} - -export function chatGptUrl(pageUrl: string): string { - // https://chatgpt.com/?hints=search&prompt= — `prompt` pre-fills the - // composer; `hints=search` makes it search-style. Uses `prompt` (not `q`). - return `https://chatgpt.com/?hints=search&prompt=${encodeURIComponent(buildPrompt(pageUrl))}`; -} - -export function claudeUrl(pageUrl: string): string { - // https://claude.ai/new?q= — same pre-fill convention as ChatGPT. - return `https://claude.ai/new?q=${encodeURIComponent(buildPrompt(pageUrl))}`; -} - -export function perplexityUrl(pageUrl: string): string { - return `https://www.perplexity.ai/search?q=${encodeURIComponent(buildPrompt(pageUrl))}`; -} - -export function grokUrl(pageUrl: string): string { - // grok.com's `q` param isn't publicly documented anywhere findable, but it - // demonstrably pre-fills the composer on Sequoia's real production site - // (confirmed the same way as chatGptUrl above) — real and working, just - // unofficial, unlike the other three. - return `https://grok.com/?q=${encodeURIComponent(buildPrompt(pageUrl))}`; -} - -export function cursorDeeplink(siteName: string, mcpUrl: string): string { - // Cursor's documented one-click MCP install deep link: - // cursor://anysphere.cursor-deeplink/mcp/install?name=&config= - // This deliberately does NOT open the doc page — it registers THIS SITE'S - // OWN MCP server (see '@inkform/framework/mcp') in the reader's Cursor, so - // they can point Cursor's agent at these docs directly. Chosen over a - // guessed `cursor://open?url=...` scheme because this is what Sequoia's - // real button actually does (confirmed by decoding its intercepted - // window.open call) and it's a materially more useful feature. - const config = safeBase64(JSON.stringify({ url: mcpUrl })); - return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(siteName)}&config=${config}`; -} - -export function vscodeDeeplink(siteName: string, mcpUrl: string): string { - // VS Code's documented MCP install URI: vscode:mcp/install? - // — note the query segment IS the encoded JSON, not key=value pairs. Same - // "install this site's MCP server" idea as cursorDeeplink above. - return `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: siteName, url: mcpUrl }))}`; -} - /* ───────────────────────────────────────────── Default icons — dependency-free, brand-neutral (no framework package bundles an icon library; see ARCHITECTURE.md §5). A theme can pass real @@ -183,7 +112,7 @@ function ExternalGlyph() { } function defaultIcon(tool: AiToolId): React.ReactNode { - return tool === 'copy' ? : ; + return ; } /* ───────────────────────────────────────────── @@ -259,21 +188,17 @@ export function AiToolMenu({ return icons?.[tool] ?? defaultIcon(tool); } - // Built once per render instead of hand-repeating six near-identical
  • - // blocks — the promptable web tools and the MCP-install tools each gate on - // a different prerequisite (a resolved page URL vs. a resolved MCP URL). + // Walk the registry (./ai-tools) once per render. Each tool resolves its + // href from a prerequisite: prompt tools need a page URL, MCP tools need a + // resolved MCP endpoint. const links: { id: AiToolId; label: string; href: string }[] = []; - if (resolvedUrl) { - links.push({ id: 'chatgpt', label: 'Open in ChatGPT', href: chatGptUrl(resolvedUrl) }); - links.push({ id: 'claude', label: 'Open in Claude', href: claudeUrl(resolvedUrl) }); - } - if (resolvedMcpUrl) { - links.push({ id: 'cursor', label: 'Connect to Cursor', href: cursorDeeplink(siteName, resolvedMcpUrl) }); - links.push({ id: 'vscode', label: 'Connect to VS Code', href: vscodeDeeplink(siteName, resolvedMcpUrl) }); - } - if (resolvedUrl) { - links.push({ id: 'perplexity', label: 'Open in Perplexity', href: perplexityUrl(resolvedUrl) }); - links.push({ id: 'grok', label: 'Open in Grok', href: grokUrl(resolvedUrl) }); + for (const tool of AI_TOOLS) { + const href = buildAiToolHref(tool, { + pageUrl: resolvedUrl, + mcpUrl: resolvedMcpUrl ?? undefined, + siteName, + }); + if (href !== null) links.push({ id: tool.id, label: tool.label, href }); } return ( @@ -286,7 +211,7 @@ export function AiToolMenu({ className={`fw-aitoolmenu-link${copied ? ' fw-aitoolmenu-link--copied' : ''}`} onClick={() => void handleCopy()} > - {copied ? : icon('copy')} + {copied ? : } {copied ? 'Copied!' : 'Copy page'}
  • diff --git a/packages/framework/src/ai-tools.ts b/packages/framework/src/ai-tools.ts new file mode 100644 index 0000000..f2e92c0 --- /dev/null +++ b/packages/framework/src/ai-tools.ts @@ -0,0 +1,106 @@ +/** + * AI tool registry — the "hand this page to an AI tool" links used by both + * AiToolMenu and PageActions. Pure data + one resolver, instead of one + * function per tool. + * + * Two kinds: + * - `prompt` — a web tool that takes the page URL and pre-fills a prompt in a + * query param (`Open in ChatGPT`, `Open in Claude`, ...). + * - `mcp` — an editor deeplink that installs THIS SITE'S OWN MCP server (see + * '@inkform/framework/mcp') into the reader's editor (`Connect to Cursor`, + * `Connect to VS Code`). Different encodings per editor, hence `format`. + */ + +export type AiToolId = 'chatgpt' | 'claude' | 'cursor' | 'vscode' | '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'; +} + +export type AiTool = PromptTool | McpTool; + +/** The prompt every web 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: 'Open in ChatGPT', base: 'https://chatgpt.com/', param: 'prompt', extra: { hints: 'search' } }, + { kind: 'prompt', id: 'claude', label: 'Open in Claude', base: 'https://claude.ai/new', param: 'q' }, + { kind: 'mcp', id: 'cursor', label: 'Connect to Cursor', format: 'cursor' }, + { kind: 'mcp', id: 'vscode', label: 'Connect to VS Code', format: 'vscode' }, + { kind: 'prompt', id: 'perplexity', label: 'Open in Perplexity', base: 'https://www.perplexity.ai/search', param: 'q' }, + { kind: 'prompt', id: 'grok', label: 'Open in Grok', base: 'https://grok.com/', param: 'q' }, +]; + +/** Unicode-safe base64 (btoa() alone only handles Latin1) — guards a siteName with non-ASCII characters. */ +export 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= + 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. + return `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: siteName, url: mcpUrl }))}`; +} + +export interface BuildAiToolHrefOptions { + /** Page URL — required for `prompt` tools. */ + pageUrl?: string; + /** MCP endpoint — required for `mcp` tools. */ + mcpUrl?: string; + /** Shown to Cursor/VS Code as the installed MCP server's label. Defaults to 'Docs'. */ + siteName?: string; +} + +/** Build one tool's href from the registry. Returns null when a prerequisite is missing. */ +export function buildAiToolHref(tool: AiTool, options: BuildAiToolHrefOptions): string | null { + if (tool.kind === 'mcp') { + if (!options.mcpUrl) return null; + return mcpInstallHref(tool.format, options.siteName ?? 'Docs', options.mcpUrl); + } + 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 url.toString(); +} + +export function aiTool(id: AiToolId): AiTool | undefined { + return AI_TOOLS.find((t) => t.id === id); +} diff --git a/packages/framework/src/page-actions.tsx b/packages/framework/src/page-actions.tsx index c0bd0b0..ec1fcf5 100644 --- a/packages/framework/src/page-actions.tsx +++ b/packages/framework/src/page-actions.tsx @@ -1,15 +1,7 @@ 'use client'; import * as React from 'react'; -import { - chatGptUrl, - claudeUrl, - cursorDeeplink, - grokUrl, - perplexityUrl, - safeOrigin, - vscodeDeeplink, -} from './ai-tool-menu'; +import { AI_TOOLS, buildAiToolHref, safeOrigin } from './ai-tools'; /** * Per-page actions: "Copy as Markdown" and an "Open" menu (view the raw @@ -245,17 +237,15 @@ export function ViewOptionsPopover({ const items: { id: string; label: string; href: string }[] = []; if (githubUrl) items.push({ id: 'github', label: 'Open in GitHub', href: githubUrl }); if (markdownUrl) items.push({ id: 'markdown', label: 'View as Markdown', href: markdownUrl }); - if (resolvedUrl) { - items.push({ id: 'chatgpt', label: 'Open in ChatGPT', href: chatGptUrl(resolvedUrl) }); - items.push({ id: 'claude', label: 'Open in Claude', href: claudeUrl(resolvedUrl) }); - } - if (resolvedMcpUrl) { - items.push({ id: 'cursor', label: 'Connect to Cursor', href: cursorDeeplink(siteName, resolvedMcpUrl) }); - items.push({ id: 'vscode', label: 'Connect to VS Code', href: vscodeDeeplink(siteName, resolvedMcpUrl) }); - } - if (resolvedUrl) { - items.push({ id: 'perplexity', label: 'Open in Perplexity', href: perplexityUrl(resolvedUrl) }); - items.push({ id: 'grok', label: 'Open in Grok', href: grokUrl(resolvedUrl) }); + // Walk the registry (./ai-tools); prompt tools need a page URL, MCP tools + // need a resolved MCP endpoint. + for (const tool of AI_TOOLS) { + const href = buildAiToolHref(tool, { + pageUrl: resolvedUrl, + mcpUrl: resolvedMcpUrl ?? undefined, + siteName, + }); + if (href !== null) items.push({ id: tool.id, label: tool.label, href }); } function icon(id: string): React.ReactNode { From f036dc5cbb9a3e9bf1adde1ddade02a972fd32c4 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Aug 2026 22:48:09 +0530 Subject: [PATCH 07/23] feat: add Google (AI Overview) link to AI tools --- packages/framework/src/ai-tools.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/framework/src/ai-tools.ts b/packages/framework/src/ai-tools.ts index f2e92c0..4ccb13c 100644 --- a/packages/framework/src/ai-tools.ts +++ b/packages/framework/src/ai-tools.ts @@ -11,7 +11,7 @@ * `Connect to VS Code`). Different encodings per editor, hence `format`. */ -export type AiToolId = 'chatgpt' | 'claude' | 'cursor' | 'vscode' | 'perplexity' | 'grok'; +export type AiToolId = 'chatgpt' | 'claude' | 'google' | 'cursor' | 'vscode' | 'perplexity' | 'grok'; interface PromptTool { kind: 'prompt'; @@ -43,6 +43,7 @@ export function buildPrompt(pageUrl: string): string { export const AI_TOOLS: AiTool[] = [ { kind: 'prompt', id: 'chatgpt', label: 'Open in ChatGPT', base: 'https://chatgpt.com/', param: 'prompt', extra: { hints: 'search' } }, { kind: 'prompt', id: 'claude', label: 'Open in Claude', base: 'https://claude.ai/new', param: 'q' }, + { kind: 'prompt', id: 'google', label: 'Google it', base: 'https://www.google.com/search', param: 'q' }, { kind: 'mcp', id: 'cursor', label: 'Connect to Cursor', format: 'cursor' }, { kind: 'mcp', id: 'vscode', label: 'Connect to VS Code', format: 'vscode' }, { kind: 'prompt', id: 'perplexity', label: 'Open in Perplexity', base: 'https://www.perplexity.ai/search', param: 'q' }, From 17d21c49f58fcfa4680bbf95924d22890dcbb1f2 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Aug 2026 22:49:10 +0530 Subject: [PATCH 08/23] feat: rename AI tool labels to Ask --- packages/framework/src/ai-tools.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/framework/src/ai-tools.ts b/packages/framework/src/ai-tools.ts index 4ccb13c..c0aa2c0 100644 --- a/packages/framework/src/ai-tools.ts +++ b/packages/framework/src/ai-tools.ts @@ -41,13 +41,13 @@ export function buildPrompt(pageUrl: string): string { } export const AI_TOOLS: AiTool[] = [ - { kind: 'prompt', id: 'chatgpt', label: 'Open in ChatGPT', base: 'https://chatgpt.com/', param: 'prompt', extra: { hints: 'search' } }, - { kind: 'prompt', id: 'claude', label: 'Open in Claude', base: 'https://claude.ai/new', param: 'q' }, - { kind: 'prompt', id: 'google', label: 'Google it', base: 'https://www.google.com/search', param: 'q' }, + { 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: 'Connect to Cursor', format: 'cursor' }, { kind: 'mcp', id: 'vscode', label: 'Connect to VS Code', format: 'vscode' }, - { kind: 'prompt', id: 'perplexity', label: 'Open in Perplexity', base: 'https://www.perplexity.ai/search', param: 'q' }, - { kind: 'prompt', id: 'grok', label: 'Open in Grok', base: 'https://grok.com/', param: 'q' }, + { 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. */ From 3df94e2bef0444a68c4b1ce9d5ea41f815fbd7ce Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Aug 2026 23:11:41 +0530 Subject: [PATCH 09/23] feat: two-column AI menu, Google/openCode tools, mobile overflow fix --- packages/framework/src/ai-tool-menu.tsx | 7 +++ packages/framework/src/ai-tools.ts | 62 +++++++++++++++----- packages/framework/src/page-actions.tsx | 70 +++++++++++++++++------ packages/framework/src/styles/widgets.css | 36 ++++++++++++ 4 files changed, 142 insertions(+), 33 deletions(-) diff --git a/packages/framework/src/ai-tool-menu.tsx b/packages/framework/src/ai-tool-menu.tsx index 6d1e541..f4cc3f4 100644 --- a/packages/framework/src/ai-tool-menu.tsx +++ b/packages/framework/src/ai-tool-menu.tsx @@ -59,6 +59,12 @@ export interface AiToolMenuProps { mcpUrl?: string | null; /** Shown to Cursor/VS Code as the installed MCP server's label. Defaults to 'Docs'. */ siteName?: string; + /** + * Absolute path to the site's own checkout on the reader's machine — used by + * opencode's deep link (`directory`), which requires a real path to prefill + * the prompt. Per-machine; pass your own docs repo path. + */ + directory?: string; /** Section heading, or `null` to omit it (e.g. stacking under a TocList that already renders "On this page"). */ title?: string | null; /** @@ -153,6 +159,7 @@ export function AiToolMenu({ pageUrl, mcpUrl, siteName = 'Docs', + directory, title = 'Ask AI', icons, className, diff --git a/packages/framework/src/ai-tools.ts b/packages/framework/src/ai-tools.ts index c0aa2c0..5f96b10 100644 --- a/packages/framework/src/ai-tools.ts +++ b/packages/framework/src/ai-tools.ts @@ -5,13 +5,14 @@ * * Two kinds: * - `prompt` — a web tool that takes the page URL and pre-fills a prompt in a - * query param (`Open in ChatGPT`, `Open in Claude`, ...). - * - `mcp` — an editor deeplink that installs THIS SITE'S OWN MCP server (see - * '@inkform/framework/mcp') into the reader's editor (`Connect to Cursor`, - * `Connect to VS Code`). Different encodings per editor, hence `format`. + * query param (`Ask ChatGPT`, `Ask Claude`, ...). + * - `mcp` — a local editor/app deeplink. Most install THIS SITE'S OWN MCP + * server (see '@inkform/framework/mcp') into the reader's editor (`Open in + * Cursor`, `Open in VS Code`); opencode instead opens a new session with a + * pre-filled prompt. Different encodings per editor, hence `format`. */ -export type AiToolId = 'chatgpt' | 'claude' | 'google' | 'cursor' | 'vscode' | 'perplexity' | 'grok'; +export type AiToolId = 'chatgpt' | 'claude' | 'google' | 'cursor' | 'vscode' | 'opencode' | 'perplexity' | 'grok'; interface PromptTool { kind: 'prompt'; @@ -29,8 +30,8 @@ interface McpTool { kind: 'mcp'; id: AiToolId; label: string; - /** How to encode the MCP install deeplink. */ - format: 'cursor' | 'vscode'; + /** How to encode the local-app deeplink. */ + format: 'cursor' | 'vscode' | 'opencode'; } export type AiTool = PromptTool | McpTool; @@ -44,8 +45,9 @@ 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: 'Connect to Cursor', format: 'cursor' }, - { kind: 'mcp', id: 'vscode', label: 'Connect to VS Code', format: 'vscode' }, + { kind: 'mcp', id: 'cursor', label: 'Open in Cursor', format: 'cursor' }, + { kind: 'mcp', id: 'vscode', label: 'Open in VS Code', format: 'vscode' }, + { kind: 'mcp', id: 'opencode', label: 'Open in OpenCode', format: 'opencode' }, { 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' }, ]; @@ -66,30 +68,60 @@ export function safeOrigin(url: string): string | undefined { } } -function mcpInstallHref(format: McpTool['format'], siteName: string, mcpUrl: string): string { +function mcpInstallHref( + format: McpTool['format'], + siteName: string, + mcpUrl: string, + prompt?: string, + directory?: string, +): string { if (format === 'cursor') { // Cursor's documented one-click MCP install deep link: // cursor://anysphere.cursor-deeplink/mcp/install?name=&config= 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. - return `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: siteName, url: mcpUrl }))}`; + if (format === 'vscode') { + // VS Code's documented MCP install URI: vscode:mcp/install? + // — note the query segment IS the encoded JSON, not key=value pairs. + return `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: siteName, url: mcpUrl }))}`; + } + // opencode Desktop (verified by reading its shipped app.asar): a new session + // with a pre-filled prompt. The `directory` param is required by the app's + // parser AND must resolve to a real project — the session page reads the + // prompt from a handoff store keyed by that directory, so a fake path opens + // the app but the prompt never lands. Pass the site's own checkout path. + return `opencode://new-session?directory=${encodeURIComponent(directory ?? '')}&prompt=${encodeURIComponent(prompt ?? '')}`; } export interface BuildAiToolHrefOptions { - /** Page URL — required for `prompt` tools. */ + /** Page URL — required for `prompt` tools (and opencode's prompt). */ pageUrl?: string; - /** MCP endpoint — required for `mcp` tools. */ + /** 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; + /** + * Absolute path to the site's own checkout on the reader's machine — used by + * opencode's deep link (`directory`). opencode requires a real directory to + * prefill the prompt, so this is a per-machine value the site configures. + */ + directory?: string; } /** Build one tool's href from the registry. Returns null when a prerequisite is missing. */ export function buildAiToolHref(tool: AiTool, options: BuildAiToolHrefOptions): string | null { if (tool.kind === 'mcp') { + if (tool.format === 'opencode') { + if (!options.pageUrl || !options.directory) return null; + return mcpInstallHref( + tool.format, + options.siteName ?? 'Docs', + '', + buildPrompt(options.pageUrl), + options.directory, + ); + } if (!options.mcpUrl) return null; return mcpInstallHref(tool.format, options.siteName ?? 'Docs', options.mcpUrl); } diff --git a/packages/framework/src/page-actions.tsx b/packages/framework/src/page-actions.tsx index ec1fcf5..c00f806 100644 --- a/packages/framework/src/page-actions.tsx +++ b/packages/framework/src/page-actions.tsx @@ -154,6 +154,12 @@ export interface ViewOptionsPopoverProps { mcpUrl?: string | null; /** Shown to Cursor/VS Code as the installed MCP server's label. Defaults to 'Docs'. */ siteName?: string; + /** + * Absolute path to the site's own checkout on the reader's machine — used by + * opencode's deep link (`directory`), which requires a real path to prefill + * the prompt. Per-machine; pass your own docs repo path. + */ + directory?: string; /** Trigger button label. Defaults to "Open". */ triggerLabel?: string; /** @@ -196,6 +202,7 @@ export function ViewOptionsPopover({ githubUrl, mcpUrl, siteName = 'Docs', + directory, triggerLabel = 'Open', icons, className, @@ -234,18 +241,24 @@ export function ViewOptionsPopover({ const resolvedUrl = liveUrl ?? ''; const resolvedMcpUrl = mcpUrl === null ? null : (mcpUrl ?? (liveOrigin ? `${liveOrigin}/api/mcp` : null)); + // Two groups: websites (github/markdown + prompt-based AI tools) and local + // tools (MCP installs for editors). Split by registry kind so the menu can + // lay them out as two columns. const items: { id: string; label: string; href: string }[] = []; + const localItems: { id: string; label: string; href: string }[] = []; if (githubUrl) items.push({ id: 'github', label: 'Open in GitHub', href: githubUrl }); if (markdownUrl) items.push({ id: 'markdown', label: 'View as Markdown', href: markdownUrl }); - // Walk the registry (./ai-tools); prompt tools need a page URL, MCP tools - // need a resolved MCP endpoint. for (const tool of AI_TOOLS) { const href = buildAiToolHref(tool, { pageUrl: resolvedUrl, mcpUrl: resolvedMcpUrl ?? undefined, siteName, + directory, }); - if (href !== null) items.push({ id: tool.id, label: tool.label, href }); + if (href === null) continue; + const entry = { id: tool.id, label: tool.label, href }; + if (tool.kind === 'mcp') localItems.push(entry); + else items.push(entry); } function icon(id: string): React.ReactNode { @@ -273,21 +286,42 @@ export function ViewOptionsPopover({ {open ? (
    - {items.map((item) => ( - - - {icon(item.id)} - - {item.label} - - ))} +
    + {items.map((item) => ( + + + {icon(item.id)} + + {item.label} + + ))} +
    + {localItems.length > 0 ? ( +
    + {localItems.map((item) => ( + + + {icon(item.id)} + + {item.label} + + ))} +
    + ) : null}
    ) : null} diff --git a/packages/framework/src/styles/widgets.css b/packages/framework/src/styles/widgets.css index 36f3325..c40c055 100644 --- a/packages/framework/src/styles/widgets.css +++ b/packages/framework/src/styles/widgets.css @@ -948,15 +948,51 @@ top: calc(100% + 0.375rem); left: 0; min-width: 15rem; + max-width: calc(100vw - 1rem); + max-height: min(22rem, calc(100dvh - 5rem)); + overflow-y: auto; z-index: 50; padding: 0.375rem; background: var(--fw-card); border: 1px solid var(--fw-border); border-radius: var(--fw-radius); box-shadow: var(--fw-shadow-lg); + display: flex; + flex-wrap: wrap; + flex-direction: row; + align-content: flex-start; + gap: 0.25rem; +} + +.fw-page-action-menu-col { display: flex; flex-direction: column; gap: 0.125rem; + flex: 1 1 9rem; + min-width: 9rem; +} + +.fw-page-action-menu-col + .fw-page-action-menu-col { + border-left: 1px solid var(--fw-border); + padding-left: 0.25rem; +} + +/* When the menu wraps to a second row (narrow viewport), drop the column + divider and let the local-tools column sit flush under the first. */ +@media (max-width: 30rem) { + .fw-page-action-menu { + right: 0; + left: auto; + min-width: 0; + } + .fw-page-action-menu-col + .fw-page-action-menu-col { + border-left: none; + padding-left: 0; + padding-top: 0.25rem; + } + .fw-page-action-menu-col { + flex-basis: 100%; + } } .fw-page-action-menu-item { From c5546f54d2a2e749c81d822518affec3c7fb5812 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Aug 2026 23:50:28 +0530 Subject: [PATCH 10/23] feat: copy terminal commands for OpenCode/Claude Code, fix menu layout --- packages/framework/src/ai-tool-menu.tsx | 53 +++++++--- packages/framework/src/ai-tools.ts | 110 +++++++++++---------- packages/framework/src/page-actions.tsx | 113 ++++++++++++---------- packages/framework/src/styles/widgets.css | 49 +++++++--- 4 files changed, 193 insertions(+), 132 deletions(-) diff --git a/packages/framework/src/ai-tool-menu.tsx b/packages/framework/src/ai-tool-menu.tsx index f4cc3f4..fa5fc5f 100644 --- a/packages/framework/src/ai-tool-menu.tsx +++ b/packages/framework/src/ai-tool-menu.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { AI_TOOLS, buildAiToolHref, buildPrompt, safeOrigin, type AiToolId } from './ai-tools'; +import { AI_TOOLS, buildAiToolAction, buildPrompt, safeOrigin, type AiToolId } from './ai-tools'; /** * AiToolMenu — a right-rail list of "hand this page to an AI tool" actions: @@ -60,9 +60,7 @@ export interface AiToolMenuProps { /** Shown to Cursor/VS Code as the installed MCP server's label. Defaults to 'Docs'. */ siteName?: string; /** - * Absolute path to the site's own checkout on the reader's machine — used by - * opencode's deep link (`directory`), which requires a real path to prefill - * the prompt. Per-machine; pass your own docs repo path. + * Absolute path to the site's own checkout on the reader's machine — used by opencode's deep link (`directory`). opencode requires a real path to prefill the prompt; without it the app still opens (falls back to `~`). Per-machine; pass your own docs repo path. */ directory?: string; /** Section heading, or `null` to omit it (e.g. stacking under a TocList that already renders "On this page"). */ @@ -165,6 +163,7 @@ export function AiToolMenu({ className, }: AiToolMenuProps): React.ReactNode { const [copied, setCopied] = React.useState(false); + const [commandCopied, setCommandCopied] = React.useState(null); // See the `pageUrl` prop doc above: both the server pass and the first // client pass (before the effect below fires) render from the same @@ -198,14 +197,21 @@ export function AiToolMenu({ // Walk the registry (./ai-tools) once per render. Each tool resolves its // href from a prerequisite: prompt tools need a page URL, MCP tools need a // resolved MCP endpoint. - const links: { id: AiToolId; label: string; href: string }[] = []; + const links: { id: AiToolId; label: string; action: { type: 'link'; href: string } | { type: 'command'; command: string } }[] = []; for (const tool of AI_TOOLS) { - const href = buildAiToolHref(tool, { + const action = buildAiToolAction(tool, { pageUrl: resolvedUrl, mcpUrl: resolvedMcpUrl ?? undefined, siteName, }); - if (href !== null) links.push({ id: tool.id, label: tool.label, href }); + if (action !== null) links.push({ id: tool.id, label: tool.label, action }); + } + + async function copyCommand(id: string, command: string) { + if (await copyText(command)) { + setCommandCopied(id); + setTimeout(() => setCommandCopied((current) => (current === id ? null : current)), 1500); + } } return ( @@ -222,14 +228,31 @@ export function AiToolMenu({ {copied ? 'Copied!' : 'Copy page'} - {links.map((l) => ( -
  • - - {icon(l.id)} - {l.label} - -
  • - ))} + {links.map((l) => { + if (l.action.type === 'command') { + const command = l.action.command; + return ( +
  • + +
  • + ); + } + return ( +
  • + + {icon(l.id)} + {l.label} + +
  • + ); + })} ); diff --git a/packages/framework/src/ai-tools.ts b/packages/framework/src/ai-tools.ts index 5f96b10..59fd9e9 100644 --- a/packages/framework/src/ai-tools.ts +++ b/packages/framework/src/ai-tools.ts @@ -1,18 +1,30 @@ /** - * AI tool registry — the "hand this page to an AI tool" links used by both + * 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. * - * Two kinds: + * Three kinds: * - `prompt` — a web tool that takes the page URL and pre-fills a prompt in a - * query param (`Ask ChatGPT`, `Ask Claude`, ...). - * - `mcp` — a local editor/app deeplink. Most install THIS SITE'S OWN MCP - * server (see '@inkform/framework/mcp') into the reader's editor (`Open in - * Cursor`, `Open in VS Code`); opencode instead opens a new session with a - * pre-filled prompt. Different encodings per editor, hence `format`. + * 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 "…"`). + * Used where a reliable deep link doesn't exist (opencode's `directory` + * requirement, claude-code's `claude-cli://` being stripped on some hosts). */ -export type AiToolId = 'chatgpt' | 'claude' | 'google' | 'cursor' | 'vscode' | 'opencode' | 'perplexity' | 'grok'; +export type AiToolId = + | 'chatgpt' + | 'claude' + | 'google' + | 'cursor' + | 'vscode' + | 'opencode' + | 'claude-code' + | 'perplexity' + | 'grok'; interface PromptTool { kind: 'prompt'; @@ -30,13 +42,21 @@ interface McpTool { kind: 'mcp'; id: AiToolId; label: string; - /** How to encode the local-app deeplink. */ - format: 'cursor' | 'vscode' | 'opencode'; + /** How to encode the MCP-install deeplink. */ + format: 'cursor' | 'vscode'; } -export type AiTool = PromptTool | McpTool; +interface CommandTool { + kind: 'command'; + id: AiToolId; + label: string; + /** Terminal command to copy; `{prompt}` is replaced with the quoted prompt. */ + command: string; +} -/** The prompt every web tool receives — read the page, ask about it. */ +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`; } @@ -47,7 +67,8 @@ export const AI_TOOLS: AiTool[] = [ { 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: 'mcp', id: 'opencode', label: 'Open in OpenCode', format: 'opencode' }, + { kind: 'command', id: 'opencode', label: 'Open in OpenCode', command: 'opencode run "{prompt}"' }, + { kind: 'command', id: 'claude-code', label: 'Open in Claude Code', command: 'claude "{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' }, ]; @@ -68,62 +89,47 @@ export function safeOrigin(url: string): string | undefined { } } -function mcpInstallHref( - format: McpTool['format'], - siteName: string, - mcpUrl: string, - prompt?: string, - directory?: string, -): string { +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= const config = safeBase64(JSON.stringify({ url: mcpUrl })); return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(siteName)}&config=${config}`; } - if (format === 'vscode') { - // VS Code's documented MCP install URI: vscode:mcp/install? - // — note the query segment IS the encoded JSON, not key=value pairs. - return `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: siteName, url: mcpUrl }))}`; - } - // opencode Desktop (verified by reading its shipped app.asar): a new session - // with a pre-filled prompt. The `directory` param is required by the app's - // parser AND must resolve to a real project — the session page reads the - // prompt from a handoff store keyed by that directory, so a fake path opens - // the app but the prompt never lands. Pass the site's own checkout path. - return `opencode://new-session?directory=${encodeURIComponent(directory ?? '')}&prompt=${encodeURIComponent(prompt ?? '')}`; + // VS Code's documented MCP install URI: vscode:mcp/install? + // — note the query segment IS the encoded JSON, not key=value pairs. + return `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: siteName, url: mcpUrl }))}`; } export interface BuildAiToolHrefOptions { - /** Page URL — required for `prompt` tools (and opencode's prompt). */ + /** 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; - /** - * Absolute path to the site's own checkout on the reader's machine — used by - * opencode's deep link (`directory`). opencode requires a real directory to - * prefill the prompt, so this is a per-machine value the site configures. - */ - directory?: string; } -/** Build one tool's href from the registry. Returns null when a prerequisite is missing. */ -export function buildAiToolHref(tool: AiTool, options: BuildAiToolHrefOptions): string | null { +/** + * 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 (tool.format === 'opencode') { - if (!options.pageUrl || !options.directory) return null; - return mcpInstallHref( - tool.format, - options.siteName ?? 'Docs', - '', - buildPrompt(options.pageUrl), - options.directory, - ); - } if (!options.mcpUrl) return null; - return mcpInstallHref(tool.format, options.siteName ?? 'Docs', options.mcpUrl); + 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); @@ -131,7 +137,7 @@ export function buildAiToolHref(tool: AiTool, options: BuildAiToolHrefOptions): for (const [key, value] of Object.entries(tool.extra ?? {})) { url.searchParams.set(key, value); } - return url.toString(); + return { type: 'link', href: url.toString() }; } export function aiTool(id: AiToolId): AiTool | undefined { diff --git a/packages/framework/src/page-actions.tsx b/packages/framework/src/page-actions.tsx index c00f806..3146a8a 100644 --- a/packages/framework/src/page-actions.tsx +++ b/packages/framework/src/page-actions.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { AI_TOOLS, buildAiToolHref, safeOrigin } from './ai-tools'; +import { AI_TOOLS, buildAiToolAction, safeOrigin, type AiToolAction } from './ai-tools'; /** * Per-page actions: "Copy as Markdown" and an "Open" menu (view the raw @@ -154,12 +154,6 @@ export interface ViewOptionsPopoverProps { mcpUrl?: string | null; /** Shown to Cursor/VS Code as the installed MCP server's label. Defaults to 'Docs'. */ siteName?: string; - /** - * Absolute path to the site's own checkout on the reader's machine — used by - * opencode's deep link (`directory`), which requires a real path to prefill - * the prompt. Per-machine; pass your own docs repo path. - */ - directory?: string; /** Trigger button label. Defaults to "Open". */ triggerLabel?: string; /** @@ -202,7 +196,6 @@ export function ViewOptionsPopover({ githubUrl, mcpUrl, siteName = 'Docs', - directory, triggerLabel = 'Open', icons, className, @@ -242,31 +235,81 @@ export function ViewOptionsPopover({ const resolvedMcpUrl = mcpUrl === null ? null : (mcpUrl ?? (liveOrigin ? `${liveOrigin}/api/mcp` : null)); // Two groups: websites (github/markdown + prompt-based AI tools) and local - // tools (MCP installs for editors). Split by registry kind so the menu can - // lay them out as two columns. - const items: { id: string; label: string; href: string }[] = []; - const localItems: { id: string; label: string; href: string }[] = []; - if (githubUrl) items.push({ id: 'github', label: 'Open in GitHub', href: githubUrl }); - if (markdownUrl) items.push({ id: 'markdown', label: 'View as Markdown', href: markdownUrl }); + // tools (MCP installs for editors + copy-a-command CLIs). Split by registry + // kind so the menu can lay them out as two columns. + interface MenuRow { + id: string; + label: string; + action: { type: 'link'; href: string } | { type: 'command'; command: string }; + } + const items: MenuRow[] = []; + const localItems: MenuRow[] = []; + if (githubUrl) items.push({ id: 'github', label: 'Open in GitHub', action: { type: 'link', href: githubUrl } }); + if (markdownUrl) items.push({ id: 'markdown', label: 'View as Markdown', action: { type: 'link', href: markdownUrl } }); for (const tool of AI_TOOLS) { - const href = buildAiToolHref(tool, { + const action = buildAiToolAction(tool, { pageUrl: resolvedUrl, mcpUrl: resolvedMcpUrl ?? undefined, siteName, - directory, }); - if (href === null) continue; - const entry = { id: tool.id, label: tool.label, href }; - if (tool.kind === 'mcp') localItems.push(entry); + if (action === null) continue; + const entry: MenuRow = { id: tool.id, label: tool.label, action }; + if (tool.kind === 'mcp' || tool.kind === 'command') localItems.push(entry); else items.push(entry); } + // Copied-state for command rows; keyed by tool id so only the clicked one flips. + const [copiedId, setCopiedId] = React.useState(null); + async function runAction(row: MenuRow) { + if (row.action.type !== 'command') return; + const ok = await copyText(row.action.command); + if (ok) { + setCopiedId(row.id); + setTimeout(() => setCopiedId((c) => (c === row.id ? null : c)), 1500); + } + } + function icon(id: string): React.ReactNode { if (icons?.[id]) return icons[id] as React.ReactNode; if (id === 'markdown') return ; return ; } + function renderRow(row: MenuRow) { + if (row.action.type === 'command') { + const copied = copiedId === row.id; + return ( + + ); + } + return ( + + + {icon(row.id)} + + {row.label} + + ); + } + return (
    diff --git a/packages/framework/src/styles/widgets.css b/packages/framework/src/styles/widgets.css index c40c055..4ff246a 100644 --- a/packages/framework/src/styles/widgets.css +++ b/packages/framework/src/styles/widgets.css @@ -947,9 +947,12 @@ position: absolute; top: calc(100% + 0.375rem); left: 0; - min-width: 15rem; + width: min(31rem, calc(100vw - 1rem)); + min-width: min(31rem, calc(100vw - 1rem)); max-width: calc(100vw - 1rem); max-height: min(22rem, calc(100dvh - 5rem)); + box-sizing: border-box; + overflow-x: hidden; overflow-y: auto; z-index: 50; padding: 0.375rem; @@ -957,19 +960,18 @@ border: 1px solid var(--fw-border); border-radius: var(--fw-radius); box-shadow: var(--fw-shadow-lg); - display: flex; - flex-wrap: wrap; - flex-direction: row; - align-content: flex-start; - gap: 0.25rem; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; } .fw-page-action-menu-col { display: flex; flex-direction: column; gap: 0.125rem; - flex: 1 1 9rem; - min-width: 9rem; + min-width: 0; + width: 100%; + overflow: hidden; } .fw-page-action-menu-col + .fw-page-action-menu-col { @@ -977,22 +979,23 @@ padding-left: 0.25rem; } -/* When the menu wraps to a second row (narrow viewport), drop the column - divider and let the local-tools column sit flush under the first. */ -@media (max-width: 30rem) { +/* Narrow viewport: stack the local-tools column under the websites column. */ +@media (max-width: 40rem) { .fw-page-action-menu { - right: 0; - left: auto; + position: fixed; + top: calc(var(--fw-header-h) + 0.5rem); + left: 0.5rem; + right: 0.5rem; + width: auto; min-width: 0; + max-width: none; + grid-template-columns: 1fr; } .fw-page-action-menu-col + .fw-page-action-menu-col { border-left: none; padding-left: 0; padding-top: 0.25rem; } - .fw-page-action-menu-col { - flex-basis: 100%; - } } .fw-page-action-menu-item { @@ -1005,6 +1008,20 @@ font-size: 0.8125rem; text-decoration: none; white-space: nowrap; + width: 100%; + min-width: 0; + border: 0; + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; +} + +.fw-page-action-menu-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .fw-page-action-menu-item:hover { From 0743019db2d20d18bac009252fd6b699014c536a Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Aug 2026 23:52:59 +0530 Subject: [PATCH 11/23] feat: copy icon + clearer labels for command tools --- packages/framework/src/ai-tool-menu.tsx | 10 ++++++---- packages/framework/src/ai-tools.ts | 4 ++-- packages/framework/src/page-actions.tsx | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/framework/src/ai-tool-menu.tsx b/packages/framework/src/ai-tool-menu.tsx index fa5fc5f..8513ff3 100644 --- a/packages/framework/src/ai-tool-menu.tsx +++ b/packages/framework/src/ai-tool-menu.tsx @@ -190,8 +190,10 @@ export function AiToolMenu({ } } - function icon(tool: AiToolId): React.ReactNode { - return icons?.[tool] ?? defaultIcon(tool); + function icon(tool: AiToolId, isCommand: boolean): React.ReactNode { + if (icons?.[tool]) return icons[tool] as React.ReactNode; + if (isCommand) return ; + return defaultIcon(tool); } // Walk the registry (./ai-tools) once per render. Each tool resolves its @@ -238,7 +240,7 @@ export function AiToolMenu({ className="fw-aitoolmenu-link" onClick={() => void copyCommand(l.id, command)} > - {commandCopied === l.id ? : icon(l.id)} + {commandCopied === l.id ? : icon(l.id, true)} {commandCopied === l.id ? 'Copied!' : l.label} @@ -247,7 +249,7 @@ export function AiToolMenu({ return (
  • - {icon(l.id)} + {icon(l.id, false)} {l.label}
  • diff --git a/packages/framework/src/ai-tools.ts b/packages/framework/src/ai-tools.ts index 59fd9e9..e063929 100644 --- a/packages/framework/src/ai-tools.ts +++ b/packages/framework/src/ai-tools.ts @@ -67,8 +67,8 @@ export const AI_TOOLS: AiTool[] = [ { 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: 'Open in OpenCode', command: 'opencode run "{prompt}"' }, - { kind: 'command', id: 'claude-code', label: 'Open in Claude Code', command: 'claude "{prompt}"' }, + { kind: 'command', id: 'opencode', label: 'Copy OpenCode command', command: 'opencode run "{prompt}"' }, + { kind: 'command', id: 'claude-code', label: 'Copy Claude Code command', command: 'claude "{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' }, ]; diff --git a/packages/framework/src/page-actions.tsx b/packages/framework/src/page-actions.tsx index 3146a8a..0d67d0e 100644 --- a/packages/framework/src/page-actions.tsx +++ b/packages/framework/src/page-actions.tsx @@ -269,8 +269,9 @@ export function ViewOptionsPopover({ } } - function icon(id: string): React.ReactNode { + function icon(id: string, isCommand: boolean): React.ReactNode { if (icons?.[id]) return icons[id] as React.ReactNode; + if (isCommand) return ; if (id === 'markdown') return ; return ; } @@ -287,7 +288,7 @@ export function ViewOptionsPopover({ onClick={() => void runAction(row)} > - {copied ? : icon(row.id)} + {copied ? : icon(row.id, true)} {copied ? 'Copied!' : row.label} @@ -303,7 +304,7 @@ export function ViewOptionsPopover({ className="fw-page-action-menu-item" > - {icon(row.id)} + {icon(row.id, false)} {row.label} From 018db8a812baffc6c1ccb5f7808e58d11e5be412 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Aug 2026 23:54:54 +0530 Subject: [PATCH 12/23] feat: shorten command tool labels --- packages/framework/src/ai-tools.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/framework/src/ai-tools.ts b/packages/framework/src/ai-tools.ts index e063929..4f4f9be 100644 --- a/packages/framework/src/ai-tools.ts +++ b/packages/framework/src/ai-tools.ts @@ -67,8 +67,8 @@ export const AI_TOOLS: AiTool[] = [ { 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: 'Copy OpenCode command', command: 'opencode run "{prompt}"' }, - { kind: 'command', id: 'claude-code', label: 'Copy Claude Code command', command: 'claude "{prompt}"' }, + { kind: 'command', id: 'opencode', label: 'OpenCode command', command: 'opencode run "{prompt}"' }, + { kind: 'command', id: 'claude-code', label: 'Claude Code command', command: 'claude "{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' }, ]; From 2e3f97377257bd3115b3ce44eb9d20001f4a59ee Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 6 Aug 2026 00:09:12 +0530 Subject: [PATCH 13/23] fix: give left AI menu column matching gutter off the divider --- packages/framework/src/styles/widgets.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/framework/src/styles/widgets.css b/packages/framework/src/styles/widgets.css index 4ff246a..7aba545 100644 --- a/packages/framework/src/styles/widgets.css +++ b/packages/framework/src/styles/widgets.css @@ -974,6 +974,10 @@ overflow: hidden; } +.fw-page-action-menu-col:first-child { + padding-right: 0.25rem; +} + .fw-page-action-menu-col + .fw-page-action-menu-col { border-left: 1px solid var(--fw-border); padding-left: 0.25rem; From a277e11b4c9467eb96ad3e46961108ca153d2d22 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 6 Aug 2026 00:51:56 +0530 Subject: [PATCH 14/23] feat: add Codex and Antigravity CLI command tools --- packages/framework/src/ai-tools.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/framework/src/ai-tools.ts b/packages/framework/src/ai-tools.ts index 4f4f9be..4d15e0c 100644 --- a/packages/framework/src/ai-tools.ts +++ b/packages/framework/src/ai-tools.ts @@ -10,7 +10,8 @@ * (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 "…"`). + * reader pastes into their own shell (`opencode run "…"`, `claude "…"`, + * `codex exec "…"`, `agy -p "…"`). * Used where a reliable deep link doesn't exist (opencode's `directory` * requirement, claude-code's `claude-cli://` being stripped on some hosts). */ @@ -23,6 +24,8 @@ export type AiToolId = | 'vscode' | 'opencode' | 'claude-code' + | 'codex' + | 'antigravity' | 'perplexity' | 'grok'; @@ -69,6 +72,8 @@ export const AI_TOOLS: AiTool[] = [ { 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' }, ]; From bfba5053c616a8df0de86acf17c6d9c67e6b1b1c Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 8 Aug 2026 18:11:02 +0530 Subject: [PATCH 15/23] feat: monochrome brand icons for AI tools, VS Code MCP fix, icon wiggle - Add monochrome brand marks (ai-tool-icons) as default icons for every AI tool in AiToolMenu and ViewOptionsPopover; icons prop still overrides - VS Code MCP install URI now includes type:http (required for URL servers) - Playful one-shot wiggle animation on icon hover with reduced-motion guard --- packages/framework/src/ai-tool-icons.tsx | 97 +++++++++++++++++++++++ packages/framework/src/ai-tool-menu.tsx | 20 +++-- packages/framework/src/ai-tools.ts | 9 ++- packages/framework/src/page-actions.tsx | 5 +- packages/framework/src/styles/layout.css | 32 ++++++++ packages/framework/src/styles/widgets.css | 7 ++ 6 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 packages/framework/src/ai-tool-icons.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 8513ff3..7d4a23f 100644 --- a/packages/framework/src/ai-tool-menu.tsx +++ b/packages/framework/src/ai-tool-menu.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { AI_TOOLS, buildAiToolAction, buildPrompt, safeOrigin, type AiToolId } from './ai-tools'; +import { defaultAiToolIcons } from './ai-tool-icons'; /** * AiToolMenu — a right-rail list of "hand this page to an AI tool" actions: @@ -19,9 +20,10 @@ import { AI_TOOLS, buildAiToolAction, buildPrompt, safeOrigin, type AiToolId } f * * 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. */ /* ───────────────────────────────────────────── @@ -75,8 +77,8 @@ 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