From b48dcca8d79e0ffedb0e99f6793a0b00222decad Mon Sep 17 00:00:00 2001 From: Charan Date: Fri, 23 Oct 2026 02:28:20 +0530 Subject: [PATCH 1/7] feat(framework): share the MDX pipeline and add route helpers for non-Next hosts Additive pieces the Astro integration builds on; the Next.js APIs and their output are unchanged. - ./mdx-options: the remark/rehype plugin lists and next-mdx-remote options renders with (it now reads them from here), plus compileMdx() for hosts that render MDX to a React element themselves. compileMdx resolves every capitalized component the source uses through the map, so the mdxComponents() fallback renders unknown components: compiled MDX copies `components` with an object spread, which can't see a Proxy's fallback. - ./routes: apiTab, apiTabSlug, docRouteSlugs, joinHref. Pure, no IO. slugify moves to a pure ./slug module; ./content re-exports it. - buildLlmsTxt({ basePath }) prefixes doc, blog and changelog links for docs served under a path. Defaults to '', so existing output is the same. - Subpath exports for browser code outside React: ./mermaid/render, ./openapi-render/try-it, ./openapi-render/sample. - next is an optional peer dependency: nothing in src imports it, and npm no longer installs Next.js into a site that doesn't use it. Tests: routes, compileMdx (pipeline, kit, fallback), llms.txt basePath. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SyuLZBytD3smoMaGTbXTYZ --- packages/framework/ARCHITECTURE.md | 62 ++++++++++- packages/framework/README.md | 7 ++ packages/framework/package.json | 8 ++ packages/framework/src/content.ts | 10 +- packages/framework/src/llms-txt.test.ts | 16 +++ packages/framework/src/llms-txt.ts | 16 ++- packages/framework/src/mdx-options.test.tsx | 59 +++++++++++ packages/framework/src/mdx-options.ts | 109 ++++++++++++++++++++ packages/framework/src/mdx.tsx | 28 +---- packages/framework/src/routes.test.ts | 67 ++++++++++++ packages/framework/src/routes.ts | 42 ++++++++ packages/framework/src/slug.ts | 13 +++ 12 files changed, 402 insertions(+), 35 deletions(-) create mode 100644 packages/framework/src/mdx-options.test.tsx create mode 100644 packages/framework/src/mdx-options.ts create mode 100644 packages/framework/src/routes.test.ts create mode 100644 packages/framework/src/routes.ts create mode 100644 packages/framework/src/slug.ts diff --git a/packages/framework/ARCHITECTURE.md b/packages/framework/ARCHITECTURE.md index ec1b5b1..daaa5da 100644 --- a/packages/framework/ARCHITECTURE.md +++ b/packages/framework/ARCHITECTURE.md @@ -83,6 +83,13 @@ latter passed straight through to Scalar's own "Generate MCP" button when `docTabs(config)`, `tabNavigation(config, tab?)`, `listDocPages(config, tab?)`, `findDocPage(config, slug)`, `docNeighbours(config, slug)`. +Already implemented in `./routes` (pure, no IO, for hosts that build their +own route tables): `apiTab(config)` (the tab with an `openapi` spec), +`apiTabSlug(config)` (its label slugified, `"API Reference"` → +`api-reference`, the rule every theme's `apiBasePath()` uses), +`docRouteSlugs(config)` (unique page slugs in nav order) and +`joinHref(base, slug)` (`('/docs', '')` → `/docs`, `('', '')` → `/`). + Already implemented in `./content`: `loadDocsConfig(dir?)`, `loadDocPage(file, dir?)`, `loadOpenApiSpec(file, dir?)` (sync, local file only), `loadOpenApiSpecForBuild(openapi, dir?)` (also handles a `https://` spec URL by fetching it at build time — used where the @@ -316,6 +323,24 @@ browser design, not something a client-side console can work around. `extractHeadings`'s slugs, for TOC anchor links) + `rehype-pretty-code` (Shiki-based syntax highlighting). +The plugin lists and options live in `./mdx-options` (file: +`src/mdx-options.ts`), shared with hosts that don't render through a React +Server Component: + +```ts +export const prettyCodeOptions: { theme: { light: string; dark: string }; keepBackground: boolean }; +export const remarkPlugins: PluggableList; // gfm, directive, callouts, mermaid fences +export const rehypePlugins: PluggableList; // slug, pretty-code +export const mdxRemoteOptions: SerializeOptions; // what passes to next-mdx-remote +export async function compileMdx(source: string, components: Record): Promise; +// Same pipeline as , returns the element to render (e.g. with +// react-dom/static's prerender). Capitalized components the source uses are +// looked up on the map before rendering, so mdxComponents()'s fallback +// renders unknown ones. (Compiled MDX copies `components` with an object +// spread, which can't see a Proxy's fallback; still passes the map +// straight through, so there an unknown component fails the render.) +``` + --- ## 5. MDX component kit — `./components` (file: `src/components.tsx`) @@ -494,7 +519,10 @@ Following the [llms.txt](https://llmstxt.org) convention: a plain-text entry point into a site's content for LLMs/agentic tools, without crawling HTML. ```ts -export interface LlmsTxtOptions { apiBasePath?: string; } // same convention as ai/ask.ts — leading slash, defaults to '/api-reference' +export interface LlmsTxtOptions { + apiBasePath?: string; // same convention as ai/ask.ts — leading slash, defaults to '/api-reference' + basePath?: string; // prefix for doc, blog and changelog links, e.g. '/docs'; defaults to '' +} export async function buildLlmsTxt(options?: LlmsTxtOptions): Promise; // Curated index: H1 title, blockquote summary, then Markdown links grouped @@ -594,3 +622,35 @@ A theme is a standalone Next.js 16 app that: Themes differ by `theme.css` tokens + fonts + small layout accents — NOT by re-implementing the shell, the routing, or any of the above. + +--- + +## 13. Hosts other than Next.js — `@inkform/astro` + +[`packages/astro`](../astro) serves a content folder from an Astro 7 site. It +builds on the pieces above instead of copying them: + +- routes from `./routes` and `./nav`, content from `./content`, the API + Reference from `./openapi` + `./openapi-engine/*`, `llms.txt` from + `./llms-txt` (with `basePath`); +- MDX through `compileMdx()` and `mdxComponents()`, and operation pages through + `OperationPage`, rendered to HTML with `react-dom/static` at build time, so + the markup is the kit's own; +- the shell as Astro components with the `fw-shell*` / `fw-sidebar*` / + `fw-toc*` markup, styled by `./styles.css` and a token file; +- browser behaviour as plain modules over that HTML, using these subpaths, + which have no React in them: + +```ts +// ./mermaid/render +export function themeConfig(el: Element, dark: boolean): MermaidConfig; +export function renderDiagram(id: string, chart: string, config: MermaidConfig): Promise<{ svg: string }>; +// ./openapi-render/try-it +export function buildTryItRequest(input: TryItRequestInput): Request; +export async function executeTryItRequest(request: Request, onChunk?: (accumulated: string) => void): Promise; +// ./openapi-render/sample +export function sampleFromSchema(schema?: JsonSchema): unknown; +``` + +`next` is an optional peer dependency: nothing under `src/` imports it. + diff --git a/packages/framework/README.md b/packages/framework/README.md index 23bd738..b45a82b 100644 --- a/packages/framework/README.md +++ b/packages/framework/README.md @@ -12,6 +12,9 @@ anywhere Next.js runs (Vercel, AWS Amplify, a Node server, a container). > `npx @inkform/cli init my-docs`, pick a theme, and get a ready-to-deploy > project. This package is for building your own theme or wiring the engine into > an existing Next.js app. +> +> On an Astro site, use [`@inkform/astro`](../astro) instead: it serves the +> same content folder through this engine, with no Next.js. ## What it gives you @@ -80,9 +83,13 @@ Content lives under `content/{docs,blog,changelog}` (override the root with | `.` | nav model, slug redirects, OpenAPI types/helpers, `FRAMEWORK_VERSION` | | `./content` | filesystem loaders: `loadDocsConfig`, `loadDocPage`, `loadBlogPosts`, `loadCollection`, `loadOpenApiSpec`, `extractHeadings` | | `./nav` | `DocsConfig`, `docTabs`, `listDocPages`, `docNeighbours` | +| `./routes` | `apiTab`, `apiTabSlug`, `docRouteSlugs`, `joinHref` — docs.json → URLs, pure | | `./openapi` | `parseOpenApi`, `normalizeOperations`, `operationNavGroups`, `curlExample`, `sampleFromSchema` | | `./mdx` | ``, `remarkMermaid` | +| `./mdx-options` | the plugin lists `` uses, and `compileMdx()` for rendering MDX outside a React Server Component | | `./mermaid` | `` — interactive diagram viewer (needs the optional `mermaid` peer) | +| `./mermaid/render` | `renderDiagram`, `themeConfig` — the viewer's React-free rendering helpers | +| `./openapi-render/try-it` · `./openapi-render/sample` | the Try It request executor and `sampleFromSchema`, React-free | | `./components` | the MDX component kit + `mdxComponents()` | | `./docs-shell` | ``, ``, ``, ``, `` — also mounts the Pagefind highlight effect, see below | | `./api-reference` | ``, `` | diff --git a/packages/framework/package.json b/packages/framework/package.json index a20da1c..a9e9fe1 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -37,17 +37,22 @@ "./prose.css": "./src/prose.css", "./content": "./src/content.ts", "./nav": "./src/nav.ts", + "./routes": "./src/routes.ts", "./openapi": "./src/openapi.ts", "./openapi-engine/nav": "./src/openapi-engine/nav.ts", "./openapi-engine/parse": "./src/openapi-engine/parse.ts", "./openapi-engine/markdown": "./src/openapi-engine/markdown.ts", "./openapi-engine/breaking-changes": "./src/openapi-engine/breaking-changes.ts", "./openapi-render": "./src/openapi-render/index.ts", + "./openapi-render/sample": "./src/openapi-render/sample.ts", + "./openapi-render/try-it": "./src/openapi-render/TryIt/request-executor.ts", "./mcp": "./src/mcp/index.ts", "./ai": "./src/ai/index.ts", "./llms-txt": "./src/llms-txt.ts", "./mdx": "./src/mdx.tsx", + "./mdx-options": "./src/mdx-options.ts", "./mermaid": "./src/mermaid/index.tsx", + "./mermaid/render": "./src/mermaid/render.ts", "./markdown": "./src/markdown.ts", "./components": "./src/components.tsx", "./docs-shell": "./src/docs-shell.tsx", @@ -83,6 +88,9 @@ "mermaid": ">=11" }, "peerDependenciesMeta": { + "next": { + "optional": true + }, "mermaid": { "optional": true } diff --git a/packages/framework/src/content.ts b/packages/framework/src/content.ts index a730a07..93ed669 100644 --- a/packages/framework/src/content.ts +++ b/packages/framework/src/content.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import matter from 'gray-matter'; import type { DocsConfig } from './nav'; +import { slugify } from './slug'; /** * Build-time content loaders. Read your site's content from the deployed repo: @@ -354,14 +355,7 @@ export function stripLeadingH1(content: string): string { } /** Slugify a heading the same way `rehype-slug`/GitHub do, for anchor links. */ -export function slugify(text: string): string { - return text - .toLowerCase() - .trim() - .replace(/[^\w\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-'); -} +export { slugify } from './slug'; /** * Extract `##`/`###` headings from MDX for the on-this-page table of contents. diff --git a/packages/framework/src/llms-txt.test.ts b/packages/framework/src/llms-txt.test.ts index 697fc8b..4386882 100644 --- a/packages/framework/src/llms-txt.test.ts +++ b/packages/framework/src/llms-txt.test.ts @@ -47,6 +47,22 @@ describe('llms.txt / llms-full.txt (real pokeapi-docs content, via DOCS_CONTENT_ expect(txt).toMatch(/- \[.+\]\(\/blog\/building-a-pokedex-with-nextjs\)/); }); + it('prefixes doc, blog and changelog links with basePath, leaving apiBasePath as given', async () => { + const txt = await buildLlmsTxt({ basePath: '/docs', apiBasePath: '/docs/api-reference' }); + expect(txt).toMatch(/- \[Quickstart\]\(\/docs\/quickstart\): .+/); + expect(txt).toContain('(/docs/api-reference/operations/get-pokemon): GET /pokemon/{name}'); + expect(txt).toContain('- [Changelog](/docs/changelog)'); + expect(txt).toMatch(/- \[.+\]\(\/docs\/blog\/building-a-pokedex-with-nextjs\)/); + expect(txt).not.toMatch(/\]\(\/quickstart\)/); + }); + + it('links the index page to the base path itself', async () => { + const root = await buildLlmsTxt(); + const based = await buildLlmsTxt({ basePath: '/docs' }); + expect(root).toMatch(/\]\(\/\)/); + expect(based).toMatch(/\]\(\/docs\)/); + }); + it('returns an empty string when no docs.json is configured', async () => { process.env.DOCS_CONTENT_ROOT = path.join(REPO_ROOT, 'this-directory-does-not-exist'); expect(await buildLlmsTxt()).toBe(''); diff --git a/packages/framework/src/llms-txt.ts b/packages/framework/src/llms-txt.ts index 9bde30f..fac7a8f 100644 --- a/packages/framework/src/llms-txt.ts +++ b/packages/framework/src/llms-txt.ts @@ -14,6 +14,7 @@ import { loadBlogPosts, loadChangelogEntries, loadDocPage, loadDocsConfig } from './content'; import { loadApiDocument } from './mcp/tools'; import { listDocPages } from './nav'; +import { joinHref } from './routes'; import { renderOpenApiMarkdown } from './openapi-engine/markdown'; import { buildNavTree } from './openapi-engine/nav'; @@ -25,6 +26,13 @@ export interface LlmsTxtOptions { * "/api-reference", matching ai/ask.ts's convention. */ apiBasePath?: string; + /** + * Prefix for doc, blog and changelog links, for a site that serves its docs + * under a path (e.g. "/docs" → `/docs/quickstart`, `/docs/blog/`). + * Defaults to "" (links from the site root). `apiBasePath` is separate and + * already absolute, so include this prefix in it yourself. + */ + basePath?: string; } function section(title: string, lines: string[]): string { @@ -43,11 +51,12 @@ export async function buildLlmsTxt(options: LlmsTxtOptions = {}): Promise Documentation for ${config.name}.`]; const docLines = listDocPages(config).map((page) => { const desc = pageDescription(page.file); - return `- [${page.title}](/${page.slug})${desc ? `: ${desc}` : ''}`; + return `- [${page.title}](${joinHref(basePath, page.slug)})${desc ? `: ${desc}` : ''}`; }); parts.push(section('Docs', docLines)); @@ -64,11 +73,12 @@ export async function buildLlmsTxt(options: LlmsTxtOptions = {}): Promise 0) { - parts.push(section('Changelog', ['- [Changelog](/changelog)'])); + parts.push(section('Changelog', [`- [Changelog](${joinHref(basePath, 'changelog')})`])); } const blogLines = loadBlogPosts().map( - (post) => `- [${post.title}](/blog/${post.slug})${post.description ? `: ${post.description}` : ''}`, + (post) => + `- [${post.title}](${joinHref(basePath, `blog/${post.slug}`)})${post.description ? `: ${post.description}` : ''}`, ); parts.push(section('Blog', blogLines)); diff --git a/packages/framework/src/mdx-options.test.tsx b/packages/framework/src/mdx-options.test.tsx new file mode 100644 index 0000000..429df84 --- /dev/null +++ b/packages/framework/src/mdx-options.test.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { prerender } from 'react-dom/static'; +import { describe, expect, it } from 'vitest'; +import { mdxComponents } from './components'; +import { compileMdx, mdxRemoteOptions, rehypePlugins, remarkPlugins } from './mdx-options'; + +async function html(source: string, extra: Record>> = {}) { + const element = await compileMdx(source, mdxComponents(extra)); + const { prelude } = await prerender(element); + return new Response(prelude).text(); +} + +describe('mdx-options', () => { + it('is the plugin list renders with', () => { + expect(mdxRemoteOptions.blockJS).toBe(false); + expect(mdxRemoteOptions.mdxOptions?.remarkPlugins).toBe(remarkPlugins); + expect(mdxRemoteOptions.mdxOptions?.rehypePlugins).toBe(rehypePlugins); + expect(remarkPlugins).toHaveLength(4); + expect(rehypePlugins).toHaveLength(2); + }); +}); + +describe('compileMdx', () => { + it('runs the shared remark pipeline: GFM, callout directives, mermaid fences', async () => { + const out = await html(':::warning\nCareful.\n:::\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n```mermaid\ngraph TD\n A --> B\n```\n'); + expect(out).toContain('fw-callout fw-callout-warning'); + expect(out).toContain(''); + expect(out).toContain('fw-mermaid-static'); + }); + + it('runs the shared rehype pipeline: heading ids and Shiki', async () => { + const out = await html('## Getting started\n\n```js\nconst a = 1;\n```\n'); + expect(out).toContain('

'); + expect(out).toContain('data-rehype-pretty-code-figure'); + expect(out).toContain('--shiki-dark'); + }); + + it('renders kit components without imports', async () => { + const out = await html('firstsecond\n\nNow.'); + expect(out).toContain('fw-tabs-trigger--active'); + expect(out).toContain('second'); + expect(out).toContain('fw-step-title'); + }); + + it('renders an unregistered component as the visible fallback instead of throwing', async () => { + const out = await html('Before\n\ninside\n\nAfter '); + const text = out.replaceAll('', ''); + expect(text).toContain('
<NotAComponent />'); + expect(text).toContain('
inside
'); + expect(text).toContain('<InlineThing />'); + }); + + it('prefers a registered component over the fallback', async () => { + const Widget = () => ok; + const out = await html('', { Widget }); + expect(out).toContain('my-widget'); + expect(out).not.toContain('fw-unknown'); + }); +}); diff --git a/packages/framework/src/mdx-options.ts b/packages/framework/src/mdx-options.ts new file mode 100644 index 0000000..ebb8cb9 --- /dev/null +++ b/packages/framework/src/mdx-options.ts @@ -0,0 +1,109 @@ +/** + * The MDX pipeline in one place: the remark/rehype plugin lists and compile + * options that `` (./mdx) renders with, plus `compileMdx()` for hosts + * that render MDX to a React element themselves instead of through a React + * Server Component (the Astro integration renders it to static HTML at build + * time). Both paths read the same lists, so a page looks the same either way. + * + * No `next` import here: next-mdx-remote's `rsc` entry only needs React. + */ + +import type { ComponentType, ReactElement } from 'react'; +import { compileMDX } from 'next-mdx-remote/rsc'; +import type { MDXRemoteProps } from 'next-mdx-remote/rsc'; +import remarkGfm from 'remark-gfm'; +import remarkDirective from 'remark-directive'; +import rehypePrettyCode from 'rehype-pretty-code'; +import rehypeSlug from 'rehype-slug'; +import { visit } from 'unist-util-visit'; +import { remarkCallouts } from './remark-directives'; +import { remarkMermaid } from './remark-mermaid'; + +type SerializeOptions = NonNullable; +type MdxCompileOptions = NonNullable; +type PluginList = NonNullable; + +/** Shiki themes for fenced code: both are emitted as CSS variables, prose.css picks one per color scheme. */ +export const prettyCodeOptions = { + theme: { light: 'github-light', dark: 'github-dark' }, + keepBackground: false, +}; + +/** GFM, `:::callout` directives (and inline `:word` kept as text), ```mermaid fences → ``. */ +export const remarkPlugins: PluginList = [remarkGfm, remarkDirective, remarkCallouts, remarkMermaid]; + +/** Heading ids that match `extractHeadings` (TOC anchors), then Shiki highlighting. */ +export const rehypePlugins: PluginList = [ + rehypeSlug, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [rehypePrettyCode as any, prettyCodeOptions], +]; + +/** + * next-mdx-remote options. v6 disables MDX `{expression}` evaluation by + * default (`blockJS: true`); authored content may use expressions, so they + * stay on, while v6's guard against dangerous expressions (`blockDangerousJS`, + * default true) stays in place. + */ +export const mdxRemoteOptions: SerializeOptions = { + blockJS: false, + mdxOptions: { remarkPlugins, rehypePlugins }, +}; + +type MdxComponent = ComponentType>; + +/** Collects every capitalized JSX element name the MDX uses (``, not `` or `
`). */ +function remarkComponentNames(names: Set) { + return () => (tree: Parameters[0]) => { + visit(tree, (node) => { + const n = node as { type: string; name?: unknown }; + if ( + (n.type === 'mdxJsxFlowElement' || n.type === 'mdxJsxTextElement') && + typeof n.name === 'string' && + /^[A-Z][\w]*$/.test(n.name) + ) { + names.add(n.name); + } + }); + }; +} + +/** + * Compiles an MDX body (no frontmatter) to a React element, with the same + * plugins and options as ``. Pass `mdxComponents()` from ./components: + * every capitalized component the source uses but the map doesn't register + * resolves through the map's fallback (a visible `fw-unknown` block) instead + * of failing the render. + * + * Why the names are collected: compiled MDX copies the `components` prop + * with an object spread, which only sees a map's own keys. A Proxy fallback + * is invisible to that, so the names the source uses are read from the tree + * during compilation and looked up on the map before React renders. + */ +export async function compileMdx( + source: string, + components: Record, +): Promise { + const names = new Set(); + const resolved: Record = { ...components }; + const { content } = await compileMDX({ + source, + components: resolved, + options: { + ...mdxRemoteOptions, + mdxOptions: { + ...mdxRemoteOptions.mdxOptions, + remarkPlugins: [...remarkPlugins, remarkComponentNames(names)], + }, + }, + }); + // `content` holds a reference to `resolved` and reads it when it renders, + // which is after this point. + for (const name of names) { + if (!(name in resolved)) { + const fallback = components[name]; + if (fallback) resolved[name] = fallback; + } + } + return content; +} diff --git a/packages/framework/src/mdx.tsx b/packages/framework/src/mdx.tsx index a4fd003..71b2be4 100644 --- a/packages/framework/src/mdx.tsx +++ b/packages/framework/src/mdx.tsx @@ -1,20 +1,10 @@ import * as React from 'react'; import { MDXRemote } from 'next-mdx-remote/rsc'; -import remarkGfm from 'remark-gfm'; -import remarkDirective from 'remark-directive'; -import rehypePrettyCode from 'rehype-pretty-code'; -import rehypeSlug from 'rehype-slug'; import { mdxComponents } from './components'; -import { remarkCallouts } from './remark-directives'; -import { remarkMermaid } from './remark-mermaid'; +import { mdxRemoteOptions } from './mdx-options'; export { remarkMermaid } from './remark-mermaid'; -const prettyCodeOptions = { - theme: { light: 'github-light', dark: 'github-dark' }, - keepBackground: false, -}; - type MdxProps = { source: string; /** Register custom widget implementations referenced in the MDX. */ @@ -35,18 +25,10 @@ export async function Mdx({ source, components }: MdxProps) {
); diff --git a/packages/framework/src/routes.test.ts b/packages/framework/src/routes.test.ts new file mode 100644 index 0000000..555d82c --- /dev/null +++ b/packages/framework/src/routes.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import type { DocsConfig } from './nav'; +import { apiTab, apiTabSlug, docRouteSlugs, joinHref } from './routes'; + +const tabbed: DocsConfig = { + name: 'Test', + navigation: [], + tabs: [ + { + tab: 'Guides', + navigation: [ + { + group: 'Start', + pages: [ + { title: 'Intro', slug: '', file: 'index.mdx' }, + { + title: 'Setup', + slug: 'setup', + file: 'setup.mdx', + children: [{ title: 'Deep', slug: 'setup/deep', file: 'setup/deep.mdx' }], + }, + { title: 'GitHub', slug: 'github', file: '', href: 'https://github.com' }, + ], + }, + { group: 'Again', pages: [{ title: 'Intro again', slug: '', file: 'index.mdx' }] }, + ], + }, + { tab: 'API Reference', openapi: 'openapi.json' }, + ], +}; + +describe('apiTab / apiTabSlug', () => { + it('finds the tab with an OpenAPI spec and slugs its label', () => { + expect(apiTab(tabbed)?.tab).toBe('API Reference'); + expect(apiTabSlug(tabbed)).toBe('api-reference'); + }); + + it('reads a single-tab config with a top-level openapi', () => { + const single: DocsConfig = { name: 'S', navigation: [], openapi: 'spec.yaml' }; + expect(apiTabSlug(single)).toBe('documentation'); + }); + + it('returns null without an API tab', () => { + const docsOnly: DocsConfig = { name: 'D', navigation: [{ group: 'G', pages: [] }] }; + expect(apiTab(docsOnly)).toBeNull(); + expect(apiTabSlug(docsOnly)).toBeNull(); + }); +}); + +describe('docRouteSlugs', () => { + it('lists page slugs in nav order, children included, links and duplicates dropped', () => { + expect(docRouteSlugs(tabbed)).toEqual(['', 'setup', 'setup/deep']); + }); +}); + +describe('joinHref', () => { + it('joins a base and a slug', () => { + expect(joinHref('/docs', 'a/b')).toBe('/docs/a/b'); + expect(joinHref('/docs/', '/a/')).toBe('/docs/a'); + }); + + it('maps the index to the base itself, or / without a base', () => { + expect(joinHref('/docs', '')).toBe('/docs'); + expect(joinHref('', '')).toBe('/'); + expect(joinHref('', 'quickstart')).toBe('/quickstart'); + }); +}); diff --git a/packages/framework/src/routes.ts b/packages/framework/src/routes.ts new file mode 100644 index 0000000..06d82d8 --- /dev/null +++ b/packages/framework/src/routes.ts @@ -0,0 +1,42 @@ +/** + * docs.json → URLs, without any framework's router in the way. Pure, no IO: + * the Next.js themes keep their own lib/route.tsx, and hosts that aren't + * Next.js (the Astro integration) build their route tables from these. + */ + +import { docTabs, listDocPages } from './nav'; +import type { DocsConfig, DocsTab } from './nav'; +import { slugify } from './slug'; + +/** The tab that carries an OpenAPI spec (the API Reference tab), or null. */ +export function apiTab(config: DocsConfig): DocsTab | null { + return docTabs(config).find((t) => !!t.openapi) ?? null; +} + +/** + * The API Reference tab's URL segment: its label, slugified ("API Reference" + * → "api-reference"). The same rule every theme's `apiBasePath()` uses, so + * operation URLs (`/operations/`) match across hosts. + * Null when there is no API tab. + */ +export function apiTabSlug(config: DocsConfig): string | null { + const tab = apiTab(config); + return tab ? slugify(tab.tab) : null; +} + +/** Every doc page slug in nav order, without duplicates. `''` is the index page. */ +export function docRouteSlugs(config: DocsConfig): string[] { + return [...new Set(listDocPages(config).map((p) => p.slug))]; +} + +/** + * Joins a base path and a slug into an href: `('/docs', 'a/b')` → `/docs/a/b`, + * `('/docs', '')` → `/docs`, `('', '')` → `/`. Extra slashes on either side are + * dropped, so `'/docs/'` and `'/a/'` join cleanly. + */ +export function joinHref(base: string, slug: string): string { + const b = base.replace(/\/+$/, ''); + const s = slug.replace(/^\/+|\/+$/g, ''); + if (!s) return b || '/'; + return `${b}/${s}`; +} diff --git a/packages/framework/src/slug.ts b/packages/framework/src/slug.ts new file mode 100644 index 0000000..c937937 --- /dev/null +++ b/packages/framework/src/slug.ts @@ -0,0 +1,13 @@ +/** + * Heading and tab slugs, the same way `rehype-slug`/GitHub make them. Pure, no + * IO, so routing code that never touches the file system (./routes) can use + * it. `./content` re-exports it, so `slugify` from there is unchanged. + */ +export function slugify(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); +} From 0e37d986cf08988733083e896737199cc78b002c Mon Sep 17 00:00:00 2001 From: Charan Date: Fri, 23 Oct 2026 02:31:09 +0530 Subject: [PATCH 2/7] feat(astro): add @inkform/astro, an Astro 7 integration for Inkform content inkform({ base, contentRoot, theme, ... }) serves a content folder from any Astro site under one base path (default /docs), with prerendered routes that work in an output: 'server' app: - docs pages from docs.json + MDX, the native API Reference (index and a page per operation), blog and changelog when their folders have entries, and llms.txt / llms-full.txt, all from one injected [...slug] route whose getStaticPaths reads the content. Two sources claiming one URL fail the build with both names. - MDX goes through the framework's pipeline (compileMdx) with every kit component available without imports, and operation pages are the framework's OperationPage; React renders both to static HTML at build time. The shell (header, tabs, sidebar, TOC, breadcrumbs, pagination, drawer) is Astro components with the framework's fw-* markup. - In the browser, small modules and no React: tabs, code groups, TOC highlight, drawer, theme toggle, search dialog, API sample pickers, Try It (the framework's request executor), newsletter form, and Mermaid diagrams when the mermaid option is on. - Pagefind indexes the built docs pages in astro:build:done, into /_pagefind/. The index comes from getFiles(): writeFiles() returned before its files were written in about one run in four, leaving an empty pagefind.js. - Works under security.csp: Shiki's inline style attributes move into one