From 649a51e5a0cae6ffdcbd9969e39bf3ece186bde8 Mon Sep 17 00:00:00 2001 From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:10:10 -0700 Subject: [PATCH 1/3] Restructure docs app and package docs --- .agents/skills/inth-docs/SKILL.md | 9 +- .github/workflows/bench.yml | 4 +- README.md | 38 +- .../docs/guides/components-fixture.mdx | 175 ----- .../guides/extracted-type-table-fixture.mdx | 11 - .../content/docs/guides/quickstart.mdx | 256 ------- apps/docs-smoke/content/docs/index.mdx | 152 ----- apps/docs-smoke/content/docs/meta.json | 10 - apps/docs-smoke/content/docs/search.mdx | 152 ----- apps/docs-smoke/scripts/llm-generate.ts | 62 -- apps/docs-smoke/src/components/docs-shell.tsx | 46 -- .../docs-smoke/src/components/site-header.tsx | 42 -- .../src/generated/docs-search-content.json | 1 - .../src/generated/docs-search-index.json | 1 - apps/docs-smoke/src/routes/__root.tsx | 41 -- .../routes/docs/guides/components-fixture.tsx | 12 - .../src/routes/docs/guides/quickstart.tsx | 12 - apps/docs-smoke/src/routes/docs/index.tsx | 53 -- apps/docs-smoke/src/routes/search.tsx | 493 -------------- apps/docs-smoke/tests/e2e/smoke.e2e.ts | 218 ------ apps/{docs-smoke => example}/.gitignore | 0 apps/{docs-smoke => example}/components.json | 0 apps/{docs-smoke => example}/package.json | 3 +- .../playwright.config.ts | 12 +- apps/{docs-smoke => example}/scripts/bench.ts | 30 +- .../scripts/convert-real.ts | 7 +- .../scripts/llm-generate-real.ts | 85 +-- apps/example/scripts/llm-generate.ts | 73 ++ .../scripts/mdx-convert.ts | 18 +- .../scripts/search-generate.ts | 2 +- .../scripts/setup-real-content.ts | 0 .../scripts/test-pipeline.ts | 4 +- .../scripts/test-real.ts | 9 +- .../src/components/component-matrix.tsx | 0 .../src/components/docs-mdx/accordion.tsx | 0 .../src/components/docs-mdx/callout.tsx | 0 .../src/components/docs-mdx/card.tsx | 0 .../src/components/docs-mdx/command-tabs.tsx | 0 .../src/components/docs-mdx/example.tsx | 0 .../src/components/docs-mdx/index.ts | 0 .../src/components/docs-mdx/mdx-components.ts | 0 .../src/components/docs-mdx/mermaid.tsx | 0 .../src/components/docs-mdx/selector.tsx | 0 .../src/components/docs-mdx/steps.tsx | 0 .../src/components/docs-mdx/tabs.tsx | 0 .../components/docs-mdx/topic-switcher.tsx | 0 .../src/components/docs-mdx/type-table.tsx | 0 apps/example/src/components/docs-shell.tsx | 51 ++ apps/example/src/components/search-bar.tsx | 409 +++++++++++ apps/example/src/components/site-footer.tsx | 40 ++ apps/example/src/components/site-header.tsx | 43 ++ .../src/components/ui/badge.tsx | 0 .../src/components/ui/button.tsx | 0 .../src/components/ui/card.tsx | 0 .../src/components/ui/separator.tsx | 0 apps/example/src/generated/docs-nav.json | 156 +++++ .../src/generated/docs-search-content.json | 1 + .../src/generated/docs-search-index.json | 1 + apps/{docs-smoke => example}/src/lib/docs.ts | 74 +- .../{docs-smoke => example}/src/lib/search.ts | 0 apps/example/src/lib/use-docs-search.ts | 412 +++++++++++ apps/{docs-smoke => example}/src/lib/utils.ts | 0 .../src/mdx-components.tsx | 0 apps/{docs-smoke => example}/src/mdx.d.ts | 0 .../src/routeTree.gen.ts | 197 +++++- apps/{docs-smoke => example}/src/router.tsx | 0 apps/example/src/routes/__root.tsx | 78 +++ .../src/routes/api/docs/ask.ts | 0 .../src/routes/api/docs/search.ts | 0 apps/example/src/routes/docs/components.tsx | 12 + apps/example/src/routes/docs/convert.tsx | 12 + .../docs/guides/bundle-package-docs.tsx | 12 + .../routes/docs/guides/connect-docs-site.tsx | 12 + apps/example/src/routes/docs/index.tsx | 12 + apps/example/src/routes/docs/lint.tsx | 12 + apps/example/src/routes/docs/llm.tsx | 12 + apps/example/src/routes/docs/methodology.tsx | 12 + apps/example/src/routes/docs/remark.tsx | 12 + .../src/routes/docs/route.tsx | 0 .../src/routes/docs/search.tsx | 2 +- .../src/routes/index.tsx | 16 +- .../src/routes/playground.tsx | 23 +- apps/example/src/routes/search.tsx | 214 ++++++ apps/{docs-smoke => example}/src/styles.css | 18 +- apps/example/tests/e2e/smoke.e2e.ts | 147 ++++ apps/{docs-smoke => example}/tsconfig.json | 0 .../type-fixtures/pipeline-example.ts | 0 apps/{docs-smoke => example}/vite.config.ts | 3 +- biome.jsonc | 2 +- bun.lock | 11 +- .../docs => docs}/components.mdx | 5 +- .../agent-docs-src/docs => docs}/convert.mdx | 0 docs/docs.config.ts | 79 +++ docs/guides/bundle-package-docs.mdx | 92 +++ docs/guides/connect-docs-site.mdx | 107 +++ .../agent-docs-src/docs => docs}/index.mdx | 5 +- docs/lint.mdx | 191 ++++++ .../docs/agent-docs-src/docs => docs}/llm.mdx | 1 + docs/methodology.mdx | 51 ++ .../agent-docs-src/docs => docs}/remark.mdx | 10 +- .../agent-docs-src/docs => docs}/search.mdx | 1 + package.json | 5 +- packages/docs/.gitignore | 9 + packages/docs/README.md | 26 +- packages/docs/agent-docs-src/docs/lint.mdx | 60 -- packages/docs/agent-docs/docs/components.md | 148 ---- packages/docs/agent-docs/docs/convert.md | 82 --- packages/docs/agent-docs/docs/index.md | 37 - packages/docs/agent-docs/docs/lint.md | 59 -- packages/docs/agent-docs/docs/llm.md | 98 --- packages/docs/agent-docs/docs/llms-full.txt | 15 - .../agent-docs/docs/llms-full/authoring.txt | 8 - .../docs/llms-full/authoring/components.txt | 156 ----- .../docs/llms-full/authoring/remark.txt | 75 -- .../agent-docs/docs/llms-full/generation.txt | 9 - .../docs/llms-full/generation/convert.txt | 92 --- .../docs/llms-full/generation/llm.txt | 108 --- .../docs/llms-full/generation/search.txt | 231 ------- .../agent-docs/docs/llms-full/overview.txt | 45 -- .../agent-docs/docs/llms-full/validation.txt | 69 -- packages/docs/agent-docs/docs/llms.txt | 34 - packages/docs/agent-docs/docs/remark.md | 67 -- packages/docs/agent-docs/docs/search.md | 223 ------ packages/docs/agent-docs/llms-full.txt | 9 - packages/docs/agent-docs/llms.txt | 21 - packages/docs/package.json | 15 +- packages/docs/scripts/generate-agent-docs.ts | 143 ---- packages/docs/scripts/generate-docs.ts | 80 +++ packages/docs/src/cli.test.ts | 276 ++++++++ packages/docs/src/cli.ts | 77 +++ packages/docs/src/cli/generate.ts | 424 ++++++++++++ packages/docs/src/index.ts | 10 + .../docs/src/internal/package-surface.test.ts | 8 +- packages/docs/src/lint/cli.ts | 43 +- packages/docs/src/lint/schema.ts | 1 + packages/docs/src/llm/index.ts | 10 +- packages/docs/src/llm/llm.test.ts | 463 +++++++------ packages/docs/src/llm/llm.ts | 641 +++++++++++------- packages/docs/tsup.config.ts | 5 +- tsconfig.json | 19 + 140 files changed, 4227 insertions(+), 4253 deletions(-) delete mode 100644 apps/docs-smoke/content/docs/guides/components-fixture.mdx delete mode 100644 apps/docs-smoke/content/docs/guides/extracted-type-table-fixture.mdx delete mode 100644 apps/docs-smoke/content/docs/guides/quickstart.mdx delete mode 100644 apps/docs-smoke/content/docs/index.mdx delete mode 100644 apps/docs-smoke/content/docs/meta.json delete mode 100644 apps/docs-smoke/content/docs/search.mdx delete mode 100644 apps/docs-smoke/scripts/llm-generate.ts delete mode 100644 apps/docs-smoke/src/components/docs-shell.tsx delete mode 100644 apps/docs-smoke/src/components/site-header.tsx delete mode 100644 apps/docs-smoke/src/generated/docs-search-content.json delete mode 100644 apps/docs-smoke/src/generated/docs-search-index.json delete mode 100644 apps/docs-smoke/src/routes/__root.tsx delete mode 100644 apps/docs-smoke/src/routes/docs/guides/components-fixture.tsx delete mode 100644 apps/docs-smoke/src/routes/docs/guides/quickstart.tsx delete mode 100644 apps/docs-smoke/src/routes/docs/index.tsx delete mode 100644 apps/docs-smoke/src/routes/search.tsx delete mode 100644 apps/docs-smoke/tests/e2e/smoke.e2e.ts rename apps/{docs-smoke => example}/.gitignore (100%) rename apps/{docs-smoke => example}/components.json (100%) rename apps/{docs-smoke => example}/package.json (97%) rename apps/{docs-smoke => example}/playwright.config.ts (76%) rename apps/{docs-smoke => example}/scripts/bench.ts (90%) rename apps/{docs-smoke => example}/scripts/convert-real.ts (78%) rename apps/{docs-smoke => example}/scripts/llm-generate-real.ts (71%) create mode 100644 apps/example/scripts/llm-generate.ts rename apps/{docs-smoke => example}/scripts/mdx-convert.ts (68%) rename apps/{docs-smoke => example}/scripts/search-generate.ts (92%) rename apps/{docs-smoke => example}/scripts/setup-real-content.ts (100%) rename apps/{docs-smoke => example}/scripts/test-pipeline.ts (89%) rename apps/{docs-smoke => example}/scripts/test-real.ts (91%) rename apps/{docs-smoke => example}/src/components/component-matrix.tsx (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/accordion.tsx (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/callout.tsx (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/card.tsx (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/command-tabs.tsx (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/example.tsx (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/index.ts (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/mdx-components.ts (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/mermaid.tsx (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/selector.tsx (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/steps.tsx (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/tabs.tsx (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/topic-switcher.tsx (100%) rename apps/{docs-smoke => example}/src/components/docs-mdx/type-table.tsx (100%) create mode 100644 apps/example/src/components/docs-shell.tsx create mode 100644 apps/example/src/components/search-bar.tsx create mode 100644 apps/example/src/components/site-footer.tsx create mode 100644 apps/example/src/components/site-header.tsx rename apps/{docs-smoke => example}/src/components/ui/badge.tsx (100%) rename apps/{docs-smoke => example}/src/components/ui/button.tsx (100%) rename apps/{docs-smoke => example}/src/components/ui/card.tsx (100%) rename apps/{docs-smoke => example}/src/components/ui/separator.tsx (100%) create mode 100644 apps/example/src/generated/docs-nav.json create mode 100644 apps/example/src/generated/docs-search-content.json create mode 100644 apps/example/src/generated/docs-search-index.json rename apps/{docs-smoke => example}/src/lib/docs.ts (74%) rename apps/{docs-smoke => example}/src/lib/search.ts (100%) create mode 100644 apps/example/src/lib/use-docs-search.ts rename apps/{docs-smoke => example}/src/lib/utils.ts (100%) rename apps/{docs-smoke => example}/src/mdx-components.tsx (100%) rename apps/{docs-smoke => example}/src/mdx.d.ts (100%) rename apps/{docs-smoke => example}/src/routeTree.gen.ts (52%) rename apps/{docs-smoke => example}/src/router.tsx (100%) create mode 100644 apps/example/src/routes/__root.tsx rename apps/{docs-smoke => example}/src/routes/api/docs/ask.ts (100%) rename apps/{docs-smoke => example}/src/routes/api/docs/search.ts (100%) create mode 100644 apps/example/src/routes/docs/components.tsx create mode 100644 apps/example/src/routes/docs/convert.tsx create mode 100644 apps/example/src/routes/docs/guides/bundle-package-docs.tsx create mode 100644 apps/example/src/routes/docs/guides/connect-docs-site.tsx create mode 100644 apps/example/src/routes/docs/index.tsx create mode 100644 apps/example/src/routes/docs/lint.tsx create mode 100644 apps/example/src/routes/docs/llm.tsx create mode 100644 apps/example/src/routes/docs/methodology.tsx create mode 100644 apps/example/src/routes/docs/remark.tsx rename apps/{docs-smoke => example}/src/routes/docs/route.tsx (100%) rename apps/{docs-smoke => example}/src/routes/docs/search.tsx (79%) rename apps/{docs-smoke => example}/src/routes/index.tsx (94%) rename apps/{docs-smoke => example}/src/routes/playground.tsx (89%) create mode 100644 apps/example/src/routes/search.tsx rename apps/{docs-smoke => example}/src/styles.css (97%) create mode 100644 apps/example/tests/e2e/smoke.e2e.ts rename apps/{docs-smoke => example}/tsconfig.json (100%) rename apps/{docs-smoke => example}/type-fixtures/pipeline-example.ts (100%) rename apps/{docs-smoke => example}/vite.config.ts (90%) rename {packages/docs/agent-docs-src/docs => docs}/components.mdx (95%) rename {packages/docs/agent-docs-src/docs => docs}/convert.mdx (100%) create mode 100644 docs/docs.config.ts create mode 100644 docs/guides/bundle-package-docs.mdx create mode 100644 docs/guides/connect-docs-site.mdx rename {packages/docs/agent-docs-src/docs => docs}/index.mdx (95%) create mode 100644 docs/lint.mdx rename {packages/docs/agent-docs-src/docs => docs}/llm.mdx (99%) create mode 100644 docs/methodology.mdx rename {packages/docs/agent-docs-src/docs => docs}/remark.mdx (81%) rename {packages/docs/agent-docs-src/docs => docs}/search.mdx (99%) create mode 100644 packages/docs/.gitignore delete mode 100644 packages/docs/agent-docs-src/docs/lint.mdx delete mode 100644 packages/docs/agent-docs/docs/components.md delete mode 100644 packages/docs/agent-docs/docs/convert.md delete mode 100644 packages/docs/agent-docs/docs/index.md delete mode 100644 packages/docs/agent-docs/docs/lint.md delete mode 100644 packages/docs/agent-docs/docs/llm.md delete mode 100644 packages/docs/agent-docs/docs/llms-full.txt delete mode 100644 packages/docs/agent-docs/docs/llms-full/authoring.txt delete mode 100644 packages/docs/agent-docs/docs/llms-full/authoring/components.txt delete mode 100644 packages/docs/agent-docs/docs/llms-full/authoring/remark.txt delete mode 100644 packages/docs/agent-docs/docs/llms-full/generation.txt delete mode 100644 packages/docs/agent-docs/docs/llms-full/generation/convert.txt delete mode 100644 packages/docs/agent-docs/docs/llms-full/generation/llm.txt delete mode 100644 packages/docs/agent-docs/docs/llms-full/generation/search.txt delete mode 100644 packages/docs/agent-docs/docs/llms-full/overview.txt delete mode 100644 packages/docs/agent-docs/docs/llms-full/validation.txt delete mode 100644 packages/docs/agent-docs/docs/llms.txt delete mode 100644 packages/docs/agent-docs/docs/remark.md delete mode 100644 packages/docs/agent-docs/docs/search.md delete mode 100644 packages/docs/agent-docs/llms-full.txt delete mode 100644 packages/docs/agent-docs/llms.txt delete mode 100644 packages/docs/scripts/generate-agent-docs.ts create mode 100644 packages/docs/scripts/generate-docs.ts create mode 100644 packages/docs/src/cli.test.ts create mode 100644 packages/docs/src/cli.ts create mode 100644 packages/docs/src/cli/generate.ts create mode 100644 packages/docs/src/index.ts create mode 100644 tsconfig.json diff --git a/.agents/skills/inth-docs/SKILL.md b/.agents/skills/inth-docs/SKILL.md index 028a5de..86cb5bd 100644 --- a/.agents/skills/inth-docs/SKILL.md +++ b/.agents/skills/inth-docs/SKILL.md @@ -13,10 +13,11 @@ Use the packaged agent docs as reference data. Prefer the installed package copy ## Path Priority -1. `node_modules/@inth/docs/agent-docs/docs/llms.txt` -2. `node_modules/@inth/docs/agent-docs/docs/.md` -3. `packages/docs/agent-docs/docs/llms.txt` -4. `packages/docs/agent-docs/docs/.md` +1. `node_modules/@inth/docs/docs/llms.txt` +2. `node_modules/@inth/docs/docs/.md` +3. `packages/docs/docs/llms.txt` (generated; run `bun run --filter @inth/docs build` first) +4. `packages/docs/docs/.md` (generated) +5. `docs/.mdx` (repo-root source — fallback when generated output is absent) ## Topic Routing diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 336768c..62f2d37 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -6,7 +6,7 @@ on: - main paths: - "packages/docs/**" - - "apps/docs-smoke/**" + - "apps/example/**" - ".github/workflows/bench.yml" # Don't stack benchmarks on rapid pushes — last one wins. @@ -44,4 +44,4 @@ jobs: - name: Run benchmark env: BENCH_RUNS: "3" - run: cd apps/docs-smoke && bun run bench + run: cd apps/example && bun run bench diff --git a/README.md b/README.md index 0d80bd0..ce8b66d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Shared docs tooling for Inth docs projects: framework-neutral MDX-to-markdown co - `@inth/docs/search/vercel`: Vercel AI Gateway / AI SDK answer streaming and bash tools - `@inth/docs/search/tanstack`: TanStack AI answer streaming and bash tools - `@inth/docs/search/cloudflare`: Cloudflare AI Gateway / Workers AI adapter helpers and bash tools -- `@inth/docs/lint`: docs validation and the `inth-docs-lint` CLI +- `@inth/docs/lint`: docs validation and the `@inth/docs lint` CLI ## Install @@ -28,7 +28,7 @@ pnpm add @inth/docs ## Live Example App -The repo includes a canonical consumer demo at `apps/docs-smoke`. +The repo includes a canonical consumer demo at `apps/example`. - Renders real `.mdx` fixture files through app-owned `mdxComponents`. - Uses TanStack Start for SSR and hydration coverage. @@ -44,16 +44,16 @@ bun run demo:dev Pipeline and browser checks: ```bash -bun run --filter docs-smoke pipeline:build -bun run --filter docs-smoke pipeline:test -bun run --filter docs-smoke test:e2e +bun run --filter example pipeline:build +bun run --filter example pipeline:test +bun run --filter example test:e2e ``` Validation layers: - Package unit tests in `packages/docs/src/**/*.test.ts*` cover framework-neutral conversion, search, linting, and generated docs behavior. -- Pipeline fixtures in `apps/docs-smoke/scripts` and `apps/docs-smoke/content` cover MDX conversion, LLM generation, and `ExtractedTypeTable`. -- The TanStack Start demo app in `apps/docs-smoke/src` covers real browser rendering and hydration. +- Pipeline fixtures in `apps/example/scripts` and `apps/example/content` cover MDX conversion, LLM generation, and `ExtractedTypeTable`. +- The TanStack Start demo app in `apps/example/src` covers real browser rendering and hydration. ## Where This Fits @@ -91,13 +91,13 @@ await convertAllMdx({ import { generateLLMFullContextFiles, generateLlmsTxt } from "@inth/docs/llm"; ``` -Run the packaged agent-doc generator locally with: +Source MDX for the package's own docs lives at the repo root in `/docs` (with `meta.json`). Run the docs generator locally with: ```bash -INTH_DOCS_AGENT_BASE_URL=https://docs.example.com/@inth/docs bun run docs:agent +INTH_DOCS_AGENT_BASE_URL=https://docs.example.com/@inth/docs bun run --filter @inth/docs docs:generate ``` -This writes a bundled reference set into `packages/docs/agent-docs/`. +This converts `/docs/*.mdx` into `packages/docs/docs/` (markdown, `llms.txt`, `llms-full.txt`, `llms-full/`). The output folder is gitignored and produced fresh at build time; only the converted output ships in the published tarball — the `.mdx` source does not. ### Generate a static search index @@ -114,18 +114,18 @@ At runtime, query the generated JSON with `@inth/docs/search`. Add a provider en ## Agent Docs -The package now ships a small, topic-scoped agent reference bundle: +The package ships a small, topic-scoped agent reference bundle in `docs/`: -- `agent-docs/docs/llms.txt`: routing index -- `agent-docs/docs/components.md` -- `agent-docs/docs/convert.md` -- `agent-docs/docs/remark.md` -- `agent-docs/docs/llm.md` -- `agent-docs/docs/search.md` -- `agent-docs/docs/lint.md` +- `docs/llms.txt`: routing index +- `docs/components.md` +- `docs/convert.md` +- `docs/remark.md` +- `docs/llm.md` +- `docs/search.md` +- `docs/lint.md` Set `INTH_DOCS_AGENT_BASE_URL` to the hosted docs base before generating publishable `llms*.txt` files. ## Repo Skill -This repo also includes a local agent skill at `.agents/skills/inth-docs/SKILL.md`. It routes agents to the packaged `agent-docs` bundle in `node_modules/@inth/docs/agent-docs` and falls back to the local workspace copy when the package is not installed. +This repo also includes a local agent skill at `.agents/skills/inth-docs/SKILL.md`. It routes agents to the packaged `docs` bundle in `node_modules/@inth/docs/docs` and falls back to the local workspace copy at `packages/docs/docs/` when the package is not installed. diff --git a/apps/docs-smoke/content/docs/guides/components-fixture.mdx b/apps/docs-smoke/content/docs/guides/components-fixture.mdx deleted file mode 100644 index 14499ef..0000000 --- a/apps/docs-smoke/content/docs/guides/components-fixture.mdx +++ /dev/null @@ -1,175 +0,0 @@ ---- -title: "Runtime Components" -description: "Render app-owned MDX components through authored docs content." ---- - -# Runtime Components - - - This page exercises the smoke app's MDX components without relying on package-owned runtime UI. - - -## Authoring Contract - -```mdx - - Render app-owned components through your shared `mdxComponents` map. - - - - - - - - - Accordion content is collapsible in the browser and still available after MDX-to-markdown conversion. - - - - - Tabs hydrate in the browser. - Use `TypeTable` when type data already exists in MDX. - - - - - B[mdxComponents] - B --> C[Rendered route] -`} /> -``` - -## Navigation Cards - - - - - - -## Browser Flow - - - - Use semantic components such as `Callout`, `Tabs`, `Cards`, `Steps`, `CommandTabs`, `Accordion`, `Example`, `TopicSwitcher`, and `TypeTable`. - - - Import the `.mdx` file directly and provide the app's `mdxComponents` through the shared runtime map. - - - Keep `ExtractedTypeTable` coverage in the conversion pipeline where source extraction has a stable file-system base path. - - - - - - - - Prefer semantic components whose content can flatten cleanly into markdown for agents, search indexes, and LLM bundles. - - - Use `TopicSwitcher` for equivalent docs slices. Frameworks are one topic category, not the whole abstraction. - - - - - - This tabset proves the app-owned components hydrate correctly inside the demo app. - - - `TypeTable` is safe to render live because all of its data is already present in the MDX payload. - - - `ExtractedTypeTable` is rendered on `/docs` with extracted type data and verified in `content/docs/guides/extracted-type-table-fixture.mdx`. - - - - - - - - The host app owns styling and runtime components while `@inth/docs` owns conversion. - - - - B[mdxComponents] - B --> C[TanStack Start route] - C --> D[Playwright coverage] -`} /> - ->", - description: "Explicit per-manager overrides when templates are not enough.", - }, - defaultManager: { - type: "\"npm\" | \"pnpm\" | \"yarn\" | \"bun\"", - description: "The tab that should be active on first render.", - default: "npm", - }, - }} -/> - - - `ExtractedTypeTable` needs extracted type data. The live extracted output appears on `/docs`, and the pipeline fixture verifies markdown output from `PipelineExampleOptions`. - diff --git a/apps/docs-smoke/content/docs/guides/extracted-type-table-fixture.mdx b/apps/docs-smoke/content/docs/guides/extracted-type-table-fixture.mdx deleted file mode 100644 index c251f69..0000000 --- a/apps/docs-smoke/content/docs/guides/extracted-type-table-fixture.mdx +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: "ExtractedTypeTable Fixture" -description: "Pipeline-only fixture for type extraction coverage." ---- - -# ExtractedTypeTable Fixture - - diff --git a/apps/docs-smoke/content/docs/guides/quickstart.mdx b/apps/docs-smoke/content/docs/guides/quickstart.mdx deleted file mode 100644 index 580fc09..0000000 --- a/apps/docs-smoke/content/docs/guides/quickstart.mdx +++ /dev/null @@ -1,256 +0,0 @@ ---- -title: "Quickstart" -description: "Wire @inth/docs into a docs app and docs pipeline." ---- - -# Quickstart - -`@inth/docs` is docs infrastructure. In a product repo such as c15t, you wire it into the docs app and docs scripts, not into the product runtime unless that runtime renders or serves docs pages. - -## What You Are Wiring - - - -For a c15t-style repo with docs under `docs/`, the wiring usually looks like this: - -```txt -repo/ - docs/ - meta.json - frameworks/react/quickstart.mdx - self-host/quickstart.mdx - scripts/ - docs-convert.ts - docs-llm.ts - docs-search.ts - public/ - docs/ - frameworks/react/quickstart.md - search-index.json - search-content.json - llms.txt -``` - -If your docs live under `content/docs/` instead, set `srcDir: "content"` and the generated markdown still lands under `{outDir}/docs`. - -## 1. Install - - - -## 2. Render MDX In Your Docs App - -Use this only in the app that renders documentation pages. - -```tsx -import { mdxComponents } from "@/components/docs-mdx"; - -export const components = { - ...mdxComponents, - // Add or override project-specific MDX components here. - // Example: Icon, APIExample, TopicSwitcher, or branded Callout. -}; -``` - -`@inth/docs` does not own your routing, sidebar, layout, hosting, framework, or runtime UI components. A React, Next.js, TanStack Start, Vite, Fumadocs, or Astro-backed docs app can still own those pieces. - -React, Vue, Nuxt, Svelte, Astro, and other stacks can use the conversion, LLM, lint, and search entry points. Runtime MDX components should live in the consuming docs app. - -Framework docs can be exposed through `TopicSwitcher` while the conversion pipeline still resolves `{framework}` placeholders from `/docs/frameworks/:framework` routes. - -## 3. Convert Authored MDX To Markdown - -Create a docs conversion script in the consuming repo. - -```ts -// scripts/docs-convert.ts -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import { convertAllMdx } from "@inth/docs/convert"; -import { defaultRemarkPlugins, remarkInclude } from "@inth/docs/remark"; - -const root = process.cwd(); -const outDir = join(root, "public"); - -await rm(outDir, { recursive: true, force: true }); -await convertAllMdx({ - // c15t-style repos have a top-level docs/ directory, so use the repo root. - srcDir: root, - outDir, - remarkPlugins: [remarkInclude, ...defaultRemarkPlugins], - enrichFrontmatterFromGit: true, -}); -``` - -Run it with: - -```bash -bun run scripts/docs-convert.ts -``` - -The output preserves the source structure: - -```txt -docs/frameworks/react/quickstart.mdx -public/docs/frameworks/react/quickstart.md -``` - -## 4. Generate Agent-Facing Files - -Run this after conversion. - -```ts -// scripts/docs-llm.ts -import { join } from "node:path"; -import { - generateLLMFullContextFiles, - generateLlmsTxt, -} from "@inth/docs/llm"; - -const root = process.cwd(); -const outDir = join(root, "public"); - -await generateLlmsTxt({ - srcDir: root, - outDir, - baseUrl: "https://c15t.com", - product: { - name: "c15t", - summary: "Open source consent and privacy platform.", - bestStartingPoints: [{ urlPath: "/docs/frameworks" }], - agentGuidance: - "Start with the framework guide that matches the user's stack.", - }, - docsSections: [ - { - title: "Frameworks", - links: [{ urlPath: "/docs/frameworks" }], - }, - { - title: "Self-host", - links: [{ urlPath: "/docs/self-host/quickstart" }], - }, - ], -}); - -await generateLLMFullContextFiles({ - outDir, - baseUrl: "https://c15t.com", - product: { name: "c15t" }, - topics: [ - { - slug: "frameworks", - title: "Frameworks", - description: "Framework integrations. Pick the matching stack.", - topics: [ - { - slug: "react", - title: "React", - description: "React integration docs.", - includePrefixes: ["frameworks/react/"], - }, - { - slug: "next", - title: "Next.js", - description: "Next.js integration docs.", - includePrefixes: ["frameworks/next/"], - }, - ], - }, - { - slug: "self-host", - title: "Self-host", - description: "Self-host c15t in your infrastructure.", - includePrefixes: ["self-host/"], - }, - ], -}); -``` - -This writes files such as: - -```txt -public/llms.txt -public/docs/llms.txt -public/docs/llms-full/frameworks/react.txt -public/docs/llms-full/frameworks/next.txt -``` - -## 5. Generate Search Data - -Run this after conversion too. - -```ts -// scripts/docs-search.ts -import { generateDocsSearchFiles } from "@inth/docs/search/node"; - -await generateDocsSearchFiles({ - outDir: "public", - baseUrl: "https://c15t.com", -}); -``` - -The generator reads `public/docs/**/*.md` and writes: - -```txt -public/docs/search-index.json -public/docs/search-content.json -``` - -## 6. Query Search In Your App - -Import the generated JSON wherever your docs app handles search. - -```ts -import { - searchDocs, - type DocsSearchContentStore, - type DocsSearchIndex, -} from "@inth/docs/search"; -import contentJson from "../public/docs/search-content.json"; -import indexJson from "../public/docs/search-index.json"; - -const index = indexJson as DocsSearchIndex; -const content = contentJson as DocsSearchContentStore; - -export function search(query: string) { - return searchDocs(index, query, { content }); -} -``` - -If the app also supports AI answers, keep model calls behind an explicit user action and use a provider entrypoint such as `@inth/docs/search/vercel` from a server route. - -## 7. Add Package Scripts - -```json -{ - "scripts": { - "docs:convert": "bun run scripts/docs-convert.ts", - "docs:llm": "bun run scripts/docs-llm.ts", - "docs:search": "bun run scripts/docs-search.ts", - "docs:build": "bun run docs:convert && bun run docs:llm && bun run docs:search" - } -} -``` - -That is the minimum wiring. Rendering is optional if another framework owns the docs UI. Conversion, LLM output, and search generation are the pieces that make the docs useful outside the website. diff --git a/apps/docs-smoke/content/docs/index.mdx b/apps/docs-smoke/content/docs/index.mdx deleted file mode 100644 index 69c9cad..0000000 --- a/apps/docs-smoke/content/docs/index.mdx +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: "@inth/docs" -description: "Developer reference for rendering MDX, converting docs, generating LLM bundles, linting content, and serving search." ---- - -# @inth/docs - -`@inth/docs` is the shared package for framework-neutral docs pipelines, LLM-friendly output, validation, and local search. Use the smallest entry point that matches where your code runs. - -## Package Surfaces - - - -## Common Implementation Paths - - - - Define `mdxComponents` in the docs app that renders your pages. `@inth/docs` intentionally does not export prebuilt UI components. - - - Use `@inth/docs/convert` with `@inth/docs/remark` to flatten authored MDX into markdown that works in LLM bundles and search indexes. - - - Generate static search JSON with `@inth/docs/search/node`, query it with `@inth/docs/search`, and add a provider entrypoint such as `@inth/docs/search/vercel` only for explicit answer requests. - - - -## Methodology - -`@inth/docs` is not trying to replace hosted docs platforms or full docs-site frameworks. It is a portable toolkit for the docs pipeline around your site. - -Use Mintlify, Fumadocs, or Starlight when the primary job is to ship a complete docs website quickly. Use `@inth/docs` when the primary job is to keep docs content useful across the website, CI, generated markdown, local search, AI answer routes, and coding-agent workflows. - -| Tool | Primary job | Best fit | -| --- | --- | --- | -| [Mintlify](https://mintlify.com/) | Managed developer documentation platform with hosted docs, API docs, and AI-oriented product features. | Teams that want a polished docs product without owning the whole site pipeline. | -| [Fumadocs](https://fumadocs.dev/) | Documentation framework and toolchain for React docs sites, especially Next.js. | Teams that want framework-level routing, navigation, UI, content adapters, search, and OpenAPI support. | -| [Starlight](https://starlight.astro.build/) | Full-featured documentation framework built on Astro. | Teams already using Astro or wanting an Astro-native docs site. | -| `@inth/docs` | Shared docs infrastructure package: framework-neutral MDX-to-markdown conversion, LLM bundles, linting, static search, and answer helpers. | Teams that want to own the app shell and runtime components while reusing one docs pipeline across sites, agents, search APIs, and internal tools. | - -These tools are not mutually exclusive. A site framework can own the public docs experience while `@inth/docs` owns conversion, validation, search artifacts, and agent-facing bundles. - -## Other Frameworks - -React, Vue, Nuxt, Svelte, Astro, and other stacks can use `@inth/docs/convert`, `@inth/docs/remark`, `@inth/docs/llm`, `@inth/docs/search`, and `@inth/docs/lint` today. Runtime MDX components belong in the consuming docs app, not this package. - -## Render MDX - -```tsx -import { mdxComponents } from "@/components/docs-mdx"; - -export const components = { - ...mdxComponents, -}; -``` - -This smoke app keeps its React MDX components under `src/components/docs-mdx`. Consumers should create their own components that match their framework, design system, and accessibility requirements. - -## Convert And Generate - -```ts -import { convertAllMdx } from "@inth/docs/convert"; -import { defaultRemarkPlugins, remarkInclude } from "@inth/docs/remark"; - -await convertAllMdx({ - srcDir: "content", - outDir: "public", - remarkPlugins: [remarkInclude, ...defaultRemarkPlugins], -}); -``` - -After conversion, use `@inth/docs/llm` to write `llms.txt` and topic-scoped full-context files, or `@inth/docs/search/node` to generate a compact search index. - -## What To Open In This App - - - - - - - - -## Validation Layers - - - - Cover framework-neutral conversion, search, linting, and generated docs behavior in `packages/docs/src/**/*.test.ts*`. - - - Cover conversion, type extraction, LLM output, and search generation in `apps/docs-smoke/scripts` and `apps/docs-smoke/content`. - - - Cover SSR, hydration, keyboard behavior, link safety, search APIs, and recipe interactions in Playwright. - - diff --git a/apps/docs-smoke/content/docs/meta.json b/apps/docs-smoke/content/docs/meta.json deleted file mode 100644 index c5ca28b..0000000 --- a/apps/docs-smoke/content/docs/meta.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "title": "Docs", - "pages": [ - "index", - "search", - "guides/quickstart", - "guides/components-fixture", - "guides/extracted-type-table-fixture" - ] -} diff --git a/apps/docs-smoke/content/docs/search.mdx b/apps/docs-smoke/content/docs/search.mdx deleted file mode 100644 index d4918e0..0000000 --- a/apps/docs-smoke/content/docs/search.mdx +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: "Search APIs" -description: "Generate static docs search data, query it at runtime, and stream source-grounded answers." ---- - -# Search APIs - -`@inth/docs` ships headless search primitives. Your app owns the UI; the package owns index generation, local ranking, source reads, answer context, and request guards. - - - Open [/search](/search) to test the generated index. Typing calls local search only. The `Ask` button is the only action that can call the model. - - -## Entrypoints - -| Use case | Import | -| --- | --- | -| Search, content reads, request guards, and rate limiting | `@inth/docs/search` | -| Vercel AI Gateway / AI SDK answer + bash tools | `@inth/docs/search/vercel` | -| TanStack AI answer + bash tools | `@inth/docs/search/tanstack` | -| Cloudflare AI Gateway / Workers AI answer + bash tools | `@inth/docs/search/cloudflare` | - -## Build The Index - -Use the Node-only entry point after MDX has been converted to markdown. - -```ts -import { generateDocsSearchFiles } from "@inth/docs/search/node"; - -await generateDocsSearchFiles({ - outDir: "public", - baseUrl: "https://docs.example.com", -}); -``` - -The generator writes `docs/search-index.json` for compact metadata and `docs/search-content.json` for answer-source text. - -## Runtime Search - -Use the provider-neutral runtime with generated JSON. - -```ts -import { - searchDocs, - type DocsSearchContentStore, - type DocsSearchIndex, -} from "@inth/docs/search"; -import contentJson from "./generated/docs-search-content.json"; -import indexJson from "./generated/docs-search-index.json"; - -const index = indexJson as DocsSearchIndex; -const content = contentJson as DocsSearchContentStore; - -const results = searchDocs(index, "package tabs", { content }); -``` - -Results include heading paths, excerpts, `urlWithHash`, and `absoluteUrlWithHash` so a docs UI can link directly to the matched section. - -## Source-Grounded Answers - -Use a provider entrypoint only when the user explicitly asks for an answer. The demo app uses Vercel AI Gateway / AI SDK. - -```ts -import { streamDocsAnswer } from "@inth/docs/search/vercel"; - -const { response, sources } = streamDocsAnswer({ - index, - content, - query, - model: process.env.DOCS_SEARCH_MODEL ?? "openai/gpt-5.4-mini", - productName: "@inth/docs", -}); -``` - -The prompt includes retrieved docs context, asks for citations, treats docs text as untrusted reference content, and tells the model to say when the retrieved context is not enough. - -TanStack AI callers pass their adapter explicitly: - -```ts -import { streamDocsAnswer } from "@inth/docs/search/tanstack"; - -const { response, sources } = streamDocsAnswer({ - index, - content, - query, - adapter, -}); -``` - -Cloudflare callers create an explicit TanStack-compatible adapter: - -```ts -import { - createCloudflareDocsAdapter, - streamDocsAnswer, -} from "@inth/docs/search/cloudflare"; - -const adapter = createCloudflareDocsAdapter({ - provider: "openai", - model: "gpt-4o", - options: { - binding: env.AI.gateway("docs-gateway"), - }, -}); - -const { response, sources } = streamDocsAnswer({ - index, - content, - query, - adapter, -}); -``` - -## Abuse Protection - - - - Debounced typing should call only a local search route such as `/api/docs/search`. - - - Keep model calls behind a clear button such as `Ask`. - - - Use `validateDocsQuery`, `readJsonWithLimit`, `getClientIdentifier`, and a `RateLimiter` implementation around public routes. - - - -The demo uses an in-memory limiter for local smoke coverage. Production apps should adapt the `RateLimiter` interface to a shared store. - -## Bash Tools - -Use each provider entrypoint when an agent should inspect docs with safe shell commands. Tools are never created inside `streamDocsAnswer`; pass both tools and tool instructions explicitly. - -```ts -import { - createDocsBashTool, - streamDocsAnswer, -} from "@inth/docs/search/vercel"; - -const { tools, instructions } = await createDocsBashTool(index, content); - -const { response } = streamDocsAnswer({ - index, - content, - query, - model, - tools, - toolInstructions: instructions, -}); -``` - -The adapter creates a read-only `/docs` filesystem for `just-bash` and wraps it with `bash-tool`. Agents can use commands such as `ls`, `cat`, `find`, `grep`, and `rg`; network commands and writes are disabled by default. diff --git a/apps/docs-smoke/scripts/llm-generate.ts b/apps/docs-smoke/scripts/llm-generate.ts deleted file mode 100644 index 1ddf647..0000000 --- a/apps/docs-smoke/scripts/llm-generate.ts +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bun -/** - * Generates /llms.txt and /docs/llms-full/*.txt from the converted markdown. - */ - -import { join } from "node:path"; -import { - generateLLMFullContextFiles, - generateLlmsTxt, -} from "../../../packages/docs/src/llm/index.ts"; - -const scriptsRoot = process.cwd(); -const srcDir = join(scriptsRoot, "content"); -const outDir = join(scriptsRoot, "public"); -const baseUrl = - process.env.DOCS_SMOKE_BASE_URL?.trim() || - process.env.BASE_URL?.trim() || - process.env.PORTLESS_URL?.trim() || - "https://docs.example.com"; - -await generateLlmsTxt({ - srcDir, - outDir, - baseUrl, - product: { - name: "Smoke SDK", - summary: "Exercise the @inth/docs pipeline end-to-end.", - bullets: [ - "Converts MDX to clean markdown via the shared remark pipeline.", - "Generates llms.txt + topic-specific full-context files.", - ], - bestStartingPoints: [{ urlPath: "/docs/guides/quickstart" }], - agentGuidance: - "Start with the quickstart, then read the full-context file for deeper work.", - }, - docsSections: [ - { - title: "Guides", - description: "Step-by-step walkthroughs.", - links: [ - { urlPath: "/docs/guides/quickstart" }, - { urlPath: "/docs/guides/components-fixture" }, - ], - }, - ], -}); - -await generateLLMFullContextFiles({ - outDir, - baseUrl, - product: { name: "Smoke SDK" }, - topics: [ - { - slug: "guides", - title: "Guides", - description: "Full context for every guide.", - includePrefixes: ["guides/"], - }, - ], -}); - -process.stdout.write("LLM files generated\n"); diff --git a/apps/docs-smoke/src/components/docs-shell.tsx b/apps/docs-smoke/src/components/docs-shell.tsx deleted file mode 100644 index 9a4f8d7..0000000 --- a/apps/docs-smoke/src/components/docs-shell.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import { Link, useRouterState } from "@tanstack/react-router"; -import type { ReactNode } from "react"; -import { navigationRoutes } from "@/lib/docs"; -import { cn } from "@/lib/utils"; -import { SiteHeader } from "./site-header"; - -export function DocsShell({ children }: { children: ReactNode }) { - const pathname = useRouterState({ - select: (state) => state.location.pathname, - }); - - return ( -
- -
- -
-
- {children} -
-
-
-
- ); -} diff --git a/apps/docs-smoke/src/components/site-header.tsx b/apps/docs-smoke/src/components/site-header.tsx deleted file mode 100644 index f148e71..0000000 --- a/apps/docs-smoke/src/components/site-header.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { Link, useRouterState } from "@tanstack/react-router"; -import { navigationRoutes } from "@/lib/docs"; -import { cn } from "@/lib/utils"; - -export function SiteHeader() { - const pathname = useRouterState({ - select: (state) => state.location.pathname, - }); - - return ( -
-
- - @inth/docs - - developer demo - - - -
-
- ); -} diff --git a/apps/docs-smoke/src/generated/docs-search-content.json b/apps/docs-smoke/src/generated/docs-search-content.json deleted file mode 100644 index 28f17de..0000000 --- a/apps/docs-smoke/src/generated/docs-search-content.json +++ /dev/null @@ -1 +0,0 @@ -{"version":2,"generatedAt":"2026-04-23T03:50:42.673Z","chunks":["Runtime Components\n\nRender app-owned MDX components through authored docs content.\n\nRuntime Components\n\n✅ Success Runtime fixture This page exercises the smoke app's MDX components without relying on package-owned runtime UI.","Runtime Components\n\nRender app-owned MDX components through authored docs content.\n\nRuntime Components\n\nAuthoring Contract\n\n```mdx Render app-owned components through your shared `mdxComponents` map. Accordion content is collapsible in the browser and still available after MDX-to-markdown conversion. Tabs hydrate in the browser. Use `TypeTable` when type data already exists in MDX. B[mdxComponents] B --> C[Rendered route] `} /> ```","Runtime Components\n\nRender app-owned MDX components through authored docs content.\n\nRuntime Components\n\nNavigation Cards\n\nQuickstart route External reference","Runtime Components\n\nRender app-owned MDX components through authored docs content.\n\nRuntime Components\n\nBrowser Flow\n\n1. Author MDX Use semantic components such as Callout , Tabs , Cards , Steps , CommandTabs , Accordion , Example , TopicSwitcher , and TypeTable . 2. Render in the app Import the .mdx file directly and provide the app's mdxComponents through the shared runtime map. 3. Validate the pipeline separately Keep ExtractedTypeTable coverage in the conversion pipeline where source extraction has a stable file-system base path. Package manager Command -- -- npm npm install @inth/docs pnpm pnpm add @inth/docs yarn yarn add @inth/docs bun bun add @inth/docs Authoring rule Prefer semantic components whose content can flatten cleanly into markdown for agents, search indexes, and LLM bundles. Naming rule Use TopicSwitcher for equivalent docs slices. Frameworks are one topic category, not the whole abstraction. Overview This tabset proves the app-owned components hydrate correctly inside the demo app. Tables TypeTable is safe to render live because all of its data is already present in the MDX payload. Pipeline note ExtractedTypeTable is rendered on /docs with extracted type data and verified in content/docs/guides/extracted-type-table-fixture.mdx .\n\n```tsx import { mdxComponents } from \"@/components/docs-mdx\"; export const components = { ...mdxComponents, }; ``` ```mermaid `flowchart LR A[Authored MDX] --> B[mdxComponents] B --> C[TanStack Start route] C --> D[Playwright coverage] ```","Runtime Components\n\nRender app-owned MDX components through authored docs content.\n\nRuntime Components\n\nBrowser Flow\n\nX payload. Pipeline note ExtractedTypeTable is rendered on /docs with extracted type data and verified in content/docs/guides/extracted-type-table-fixture.mdx . Framework React — React integration Next.js — Next.js integration Svelte — Svelte integration Render MDX Preview the output and inspect the source. ✅ Success Runtime components The host app owns styling and runtime components while @inth/docs owns conversion. mdx-components.tsx Property Type Description Default Required -- -- -- -- -- command string Package name, CLI name, or custom command template with a \\ pm placeholder. - ✅ Required mode \"install\" \\ \"run\" \\ \"create\" Optional expansion mode for package names, CLI names, or project starters such as \\ pnpm create next-app\\ . - Optional commands Partial\\ ({ - meta: [ - { charSet: "utf-8" }, - { - content: "width=device-width, initial-scale=1", - name: "viewport", - }, - { - title: "@inth/docs reference app", - }, - { - content: - "Reference routes for MDX, components, and playground coverage.", - name: "description", - }, - ], - links: [{ rel: "stylesheet", href: appCss }], - }), - shellComponent: RootDocument, -}); - -function RootDocument({ children }: { children: ReactNode }) { - return ( - - - - - - {children} - - - - ); -} diff --git a/apps/docs-smoke/src/routes/docs/guides/components-fixture.tsx b/apps/docs-smoke/src/routes/docs/guides/components-fixture.tsx deleted file mode 100644 index 83cbea9..0000000 --- a/apps/docs-smoke/src/routes/docs/guides/components-fixture.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import { createFileRoute } from "@tanstack/react-router"; -import ComponentsFixtureDoc from "../../../../content/docs/guides/components-fixture.mdx"; - -export const Route = createFileRoute("/docs/guides/components-fixture")({ - component: ComponentsFixtureRoute, -}); - -function ComponentsFixtureRoute() { - return ; -} diff --git a/apps/docs-smoke/src/routes/docs/guides/quickstart.tsx b/apps/docs-smoke/src/routes/docs/guides/quickstart.tsx deleted file mode 100644 index b37354f..0000000 --- a/apps/docs-smoke/src/routes/docs/guides/quickstart.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import { createFileRoute } from "@tanstack/react-router"; -import QuickstartDoc from "../../../../content/docs/guides/quickstart.mdx"; - -export const Route = createFileRoute("/docs/guides/quickstart")({ - component: QuickstartRoute, -}); - -function QuickstartRoute() { - return ; -} diff --git a/apps/docs-smoke/src/routes/docs/index.tsx b/apps/docs-smoke/src/routes/docs/index.tsx deleted file mode 100644 index 3674b22..0000000 --- a/apps/docs-smoke/src/routes/docs/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { createFileRoute } from "@tanstack/react-router"; -import { createServerFn } from "@tanstack/react-start"; -import { Callout, ExtractedTypeTable } from "@/components/docs-mdx"; -import DocsIndex from "../../../content/docs/index.mdx"; - -const routeDirectory = dirname(fileURLToPath(import.meta.url)); -const docsSmokeRoot = resolve(routeDirectory, "..", "..", ".."); -const repoRoot = resolve(docsSmokeRoot, "..", ".."); -const extractedTypeTableExamplePromise = (async () => { - const { extractTypeFromFile } = await import("@inth/docs/remark"); - - return { - properties: - extractTypeFromFile( - "./apps/docs-smoke/type-fixtures/pipeline-example.ts", - "PipelineExampleOptions", - repoRoot - ) ?? null, - }; -})(); - -const getExtractedTypeTableExample = createServerFn({ method: "GET" }).handler( - async () => extractedTypeTableExamplePromise -); - -export const Route = createFileRoute("/docs/")({ - loader: async () => getExtractedTypeTableExample(), - component: DocsIndexRoute, -}); - -function DocsIndexRoute() { - const { properties } = Route.useLoaderData(); - - return ( - <> - -

ExtractedTypeTable

- {properties ? ( - - ) : ( - - Could not extract `PipelineExampleOptions` from the fixture file. - - )} - - ); -} diff --git a/apps/docs-smoke/src/routes/search.tsx b/apps/docs-smoke/src/routes/search.tsx deleted file mode 100644 index f619c5d..0000000 --- a/apps/docs-smoke/src/routes/search.tsx +++ /dev/null @@ -1,493 +0,0 @@ -"use client"; - -import { createFileRoute } from "@tanstack/react-router"; -import type { FormEvent } from "react"; -import { useCallback, useEffect, useId, useRef, useState } from "react"; -import { Streamdown } from "streamdown"; -import { SiteHeader } from "@/components/site-header"; -import type { DemoSearchApiResult } from "@/lib/search"; - -interface AnswerConfig { - enabled: boolean; - model: string; -} - -type SearchStatus = "idle" | "loading" | "error"; -type AnswerStatus = "idle" | "loading" | "streaming" | "error" | "disabled"; - -const SEARCH_DEBOUNCE_MS = 250; -const SEARCH_MAX_QUERY_LENGTH = 400; - -export const Route = createFileRoute("/search")({ - component: SearchRoute, -}); - -function isAbortError(error: unknown): boolean { - return error instanceof DOMException && error.name === "AbortError"; -} - -async function readAnswerStream( - response: Response, - options: { - isCurrent: () => boolean; - onText: (text: string) => void; - signal: AbortSignal; - } -): Promise { - if (!response.body) { - return ""; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let streamedAnswer = ""; - while (true) { - const chunk = await reader.read(); - if (options.signal.aborted || !options.isCurrent()) { - await reader.cancel(); - return; - } - if (chunk.done) { - break; - } - const text = decoder.decode(chunk.value, { stream: true }); - streamedAnswer += text; - options.onText(text); - } - - const remainingText = decoder.decode(); - if (remainingText) { - streamedAnswer += remainingText; - options.onText(remainingText); - } - return streamedAnswer; -} - -async function readAnswerErrorMessage(response: Response): Promise { - const data = (await response.json().catch(() => null)) as { - error?: string; - } | null; - return data?.error ?? "Answer generation failed."; -} - -function SearchRoute() { - const inputId = useId(); - const searchTimeoutRef = useRef(undefined); - const searchControllerRef = useRef(null); - const askControllerRef = useRef(null); - const askRequestIdRef = useRef(0); - const [query, setQuery] = useState("tabs"); - const [searchStatus, setSearchStatus] = useState("idle"); - const [answerStatus, setAnswerStatus] = useState("idle"); - const [results, setResults] = useState([]); - const [answer, setAnswer] = useState(""); - const [error, setError] = useState(""); - const [answerConfig, setAnswerConfig] = useState({ - enabled: false, - model: "moonshotai/kimi-k2.6", - }); - - useEffect(() => { - let active = true; - async function loadAnswerConfig() { - const response = await fetch("/api/docs/ask"); - if (!response.ok) { - return; - } - const data = (await response.json()) as AnswerConfig; - if (active) { - setAnswerConfig(data); - setAnswerStatus(data.enabled ? "idle" : "disabled"); - } - } - const configPromise = loadAnswerConfig(); - configPromise.catch(() => undefined); - return () => { - active = false; - }; - }, []); - - const runSearch = useCallback( - async (nextQuery: string, signal?: AbortSignal) => { - const trimmedQuery = nextQuery.trim(); - if (!trimmedQuery) { - return []; - } - - try { - setSearchStatus("loading"); - setError(""); - const response = await fetch( - `/api/docs/search?q=${encodeURIComponent(trimmedQuery)}`, - { signal } - ); - const data = (await response.json()) as - | DemoSearchApiResult - | { error: string }; - - if (!response.ok || "error" in data) { - setSearchStatus("error"); - const message = "error" in data ? data.error : "Search failed."; - setError(message); - return []; - } - - setResults(data.results); - setSearchStatus("idle"); - return data.results; - } catch (caughtError) { - if (isAbortError(caughtError)) { - return []; - } - setSearchStatus("error"); - setError("Search failed."); - return []; - } - }, - [] - ); - - const cancelPendingSearch = useCallback(() => { - if (searchTimeoutRef.current !== undefined) { - window.clearTimeout(searchTimeoutRef.current); - searchTimeoutRef.current = undefined; - } - searchControllerRef.current?.abort(); - searchControllerRef.current = null; - }, []); - - const cancelPendingAnswer = useCallback(() => { - askRequestIdRef.current += 1; - askControllerRef.current?.abort(); - askControllerRef.current = null; - }, []); - - useEffect(() => { - const trimmedQuery = query.trim(); - if (!trimmedQuery) { - cancelPendingSearch(); - setResults([]); - setSearchStatus("idle"); - setError(""); - return; - } - - cancelPendingSearch(); - const controller = new AbortController(); - searchControllerRef.current = controller; - searchTimeoutRef.current = window.setTimeout(() => { - searchTimeoutRef.current = undefined; - const searchPromise = runSearch(trimmedQuery, controller.signal); - searchPromise.finally(() => { - if (searchControllerRef.current === controller) { - searchControllerRef.current = null; - } - }); - }, SEARCH_DEBOUNCE_MS); - - return () => { - if (searchTimeoutRef.current !== undefined) { - window.clearTimeout(searchTimeoutRef.current); - searchTimeoutRef.current = undefined; - } - if (searchControllerRef.current === controller) { - controller.abort(); - searchControllerRef.current = null; - } - }; - }, [cancelPendingSearch, query, runSearch]); - - useEffect( - () => () => { - cancelPendingAnswer(); - }, - [cancelPendingAnswer] - ); - - async function handleSearch(event: FormEvent) { - event.preventDefault(); - cancelPendingSearch(); - cancelPendingAnswer(); - setAnswer(""); - const controller = new AbortController(); - searchControllerRef.current = controller; - try { - await runSearch(query, controller.signal); - } finally { - if (searchControllerRef.current === controller) { - searchControllerRef.current = null; - } - } - } - - function isCurrentAnswer( - controller: AbortController, - requestId: number - ): boolean { - return !controller.signal.aborted && askRequestIdRef.current === requestId; - } - - function setAnswerError(message: string) { - setAnswerStatus("error"); - setError(message); - } - - async function searchAnswerSources( - trimmedQuery: string, - controller: AbortController, - requestId: number - ): Promise { - const nextResults = await runSearch(trimmedQuery, controller.signal); - if (!isCurrentAnswer(controller, requestId)) { - return false; - } - if (nextResults.length === 0) { - setAnswerError("No matching docs were found for that question."); - return false; - } - return true; - } - - async function streamAnswer( - trimmedQuery: string, - controller: AbortController, - requestId: number - ): Promise { - const response = await fetch("/api/docs/ask", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ query: trimmedQuery }), - signal: controller.signal, - }); - - if (!isCurrentAnswer(controller, requestId)) { - return; - } - if (!(response.ok && response.body)) { - setAnswerError(await readAnswerErrorMessage(response)); - return; - } - - setAnswerStatus("streaming"); - const streamedAnswer = await readAnswerStream(response, { - isCurrent: () => askRequestIdRef.current === requestId, - onText: (text) => setAnswer((current) => current + text), - signal: controller.signal, - }); - if (!isCurrentAnswer(controller, requestId)) { - return; - } - if (!streamedAnswer?.trim()) { - setAnswerError( - "The AI provider returned an empty answer. Check AI Gateway auth and model access." - ); - return; - } - setAnswerStatus("idle"); - } - - async function handleAsk() { - const trimmedQuery = query.trim(); - if (!(trimmedQuery && answerConfig.enabled)) { - return; - } - - cancelPendingSearch(); - cancelPendingAnswer(); - const requestId = askRequestIdRef.current + 1; - askRequestIdRef.current = requestId; - const controller = new AbortController(); - askControllerRef.current = controller; - - try { - setAnswer(""); - setError(""); - setAnswerStatus("loading"); - const hasSources = await searchAnswerSources( - trimmedQuery, - controller, - requestId - ); - if (!hasSources) { - return; - } - await streamAnswer(trimmedQuery, controller, requestId); - } catch (caughtError) { - if (isAbortError(caughtError)) { - return; - } - setAnswerError("Answer generation failed."); - } finally { - if (askControllerRef.current === controller) { - askControllerRef.current = null; - } - } - } - - const canAsk = query.trim().length > 0 && answerConfig.enabled; - - return ( -
- -
-
-
-

- Local index plus optional AI answer -

-

- Search the docs -

-

- Typing queries the generated static index through - `/api/docs/search`. The model is called only when the Ask button - is enabled and pressed. -

-
- -
- - { - cancelPendingAnswer(); - setQuery(event.target.value); - }} - placeholder="Search docs or ask a question" - value={query} - /> -
- - -
-
- - {error ? ( -

- {error} -

- ) : null} - -
-

- Results -

- {results.length > 0 ? ( - - ) : ( -

- Type a docs term such as install, tabs, or search. -

- )} -
-
- - -
-
- ); -} diff --git a/apps/docs-smoke/tests/e2e/smoke.e2e.ts b/apps/docs-smoke/tests/e2e/smoke.e2e.ts deleted file mode 100644 index 5cc8c8b..0000000 --- a/apps/docs-smoke/tests/e2e/smoke.e2e.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { expect, type Page, test } from "@playwright/test"; - -const DASHBOARD_HEADING = /Build docs with @inth\/docs/i; -const QUICKSTART_ROUTE_LINK = /Quickstart/; -const AI_DISABLED_MESSAGE = /AI answers are disabled/i; -const QUICKSTART_INSTALL_HEADING_HREF = "/docs/guides/quickstart#1-install"; -const COMPONENT_FIXTURE_CALLOUT_COUNT = 3; - -async function waitForClientHydration(page: Page): Promise { - await page.waitForFunction( - () => document.readyState === "complete" && !("$_TSR" in window) - ); -} - -test("home route renders the developer dashboard and pipeline package surfaces", async ({ - page, - request, -}) => { - const response = await request.get("/"); - const html = await response.text(); - - expect(html).toContain("Build docs with"); - expect(html).toContain("Implementation contract"); - expect(html).toContain("@inth/docs/search/vercel"); - - await page.goto("/", { waitUntil: "networkidle" }); - await expect(page.getByText(DASHBOARD_HEADING)).toBeVisible(); - await expect( - page.getByRole("link", { name: QUICKSTART_ROUTE_LINK }).first() - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Package surfaces", exact: true }) - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Smoke coverage", exact: true }) - ).toBeVisible(); -}); - -test("docs route renders docs pipeline reference and extracted ExtractedTypeTable output", async ({ - page, - request, -}) => { - const response = await request.get("/docs"); - const html = await response.text(); - - expect(html).toContain("@inth/docs"); - expect(html).toContain("@inth/docs/search/cloudflare"); - expect(html).toContain("PipelineExampleOptions"); - - await page.goto("/docs", { waitUntil: "networkidle" }); - await expect( - page.getByRole("heading", { name: "@inth/docs", exact: true }) - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "ExtractedTypeTable", exact: true }) - ).toBeVisible(); - const extractedTypeTable = page.locator("[data-inth-extracted-type-table]"); - await expect(extractedTypeTable).toContainText("PipelineExampleOptions"); - await expect(extractedTypeTable).toContainText("value"); - await expect(extractedTypeTable).toContainText("label"); - await expect(extractedTypeTable).toContainText("featured"); -}); - -test("search docs route explains the headless search APIs", async ({ - page, - request, -}) => { - const response = await request.get("/docs/search"); - const html = await response.text(); - - expect(html).toContain("Search APIs"); - expect(html).toContain("@inth/docs/search"); - - await page.goto("/docs/search", { waitUntil: "networkidle" }); - await expect( - page.getByRole("heading", { name: "Search APIs", exact: true }) - ).toBeVisible(); - await expect( - page.getByRole("link", { name: "/search", exact: true }) - ).toBeVisible(); -}); - -test("quickstart route renders MDX content on the server and hydrates app-owned components", async ({ - page, - request, -}) => { - const response = await request.get("/docs/guides/quickstart"); - const html = await response.text(); - - expect(html).toContain("Quickstart"); - expect(html).toContain("What You Are Wiring"); - expect(html).toContain("scripts/docs-convert.ts"); - expect(html).toContain("Package manager"); - - await page.goto("/docs/guides/quickstart", { waitUntil: "networkidle" }); - await waitForClientHydration(page); - await expect( - page.getByRole("heading", { name: "Quickstart", exact: true }) - ).toBeVisible(); - - const packageManager = page.getByRole("button", { name: "pnpm" }); - await packageManager.click(); - await expect(page.locator("[data-inth-command-tabs-output]")).toContainText( - "pnpm add @inth/docs" - ); - await expect( - page - .locator("code") - .filter({ hasText: "public/docs/search-index.json" }) - .first() - ).toBeVisible(); - await expect( - page.locator("code").filter({ hasText: "docs:build" }).first() - ).toBeVisible(); -}); - -test("components fixture renders app-owned MDX components and preserves external link safety", async ({ - page, - request, -}) => { - const response = await request.get("/docs/guides/components-fixture"); - const html = await response.text(); - - expect(html).toContain("Runtime Components"); - expect(html).toContain("Runtime fixture"); - - await page.goto("/docs/guides/components-fixture", { - waitUntil: "networkidle", - }); - await waitForClientHydration(page); - await expect( - page.getByRole("heading", { name: "Runtime Components", exact: true }) - ).toBeVisible(); - await expect(page.locator("[data-inth-callout]")).toHaveCount( - COMPONENT_FIXTURE_CALLOUT_COUNT - ); - await expect(page.locator("[data-inth-accordion]")).toBeVisible(); - await expect(page.locator("[data-inth-cards]")).toBeVisible(); - await expect(page.locator("[data-inth-example]")).toBeVisible(); - await expect(page.locator("[data-inth-steps]")).toBeVisible(); - await expect(page.locator("[data-inth-topic-switcher]")).toBeVisible(); - - const overview = page.getByRole("tab", { name: "Overview" }); - const tables = page.getByRole("tab", { name: "Tables" }); - await overview.focus(); - await page.keyboard.press("ArrowRight"); - await expect(tables).toHaveAttribute("aria-selected", "true"); - await expect(tables).toBeFocused(); - - const externalCard = page.locator('a[href="https://example.com/docs"]'); - await expect(externalCard).toHaveAttribute("target", "_blank"); - await expect(externalCard).toHaveAttribute("rel", "noopener"); -}); - -test("playground route updates selector content", async ({ page }) => { - await page.goto("/playground", { waitUntil: "networkidle" }); - await waitForClientHydration(page); - await expect(page.getByText("Recipes playground")).toBeVisible(); - - await page.selectOption("[data-inth-selector-control]", "convert"); - const selectorContent = page.locator("[data-inth-selector-content]"); - await expect(selectorContent).toHaveAttribute("data-value", "convert"); - await expect(selectorContent).toContainText("Convert For Agents"); - await expect(selectorContent).toContainText("defaultRemarkPlugins"); - - await page.selectOption("[data-inth-selector-control]", "search"); - await expect(selectorContent).toHaveAttribute("data-value", "search"); - await expect(selectorContent).toContainText("streamDocsAnswer"); - await expect( - selectorContent.getByRole("link", { name: "Open live search" }) - ).toBeVisible(); -}); - -test("search route returns local docs results and answer configuration state", async ({ - page, - request, -}) => { - const answerConfigResponse = await request.get("/api/docs/ask"); - const answerConfig = (await answerConfigResponse.json()) as { - enabled: boolean; - }; - - await page.goto("/search", { waitUntil: "networkidle" }); - await waitForClientHydration(page); - await expect( - page.getByRole("heading", { name: "Search the docs", exact: true }) - ).toBeVisible(); - - const installSearchResponse = page.waitForResponse( - (response) => - response.url().includes("/api/docs/search?q=install") && response.ok() - ); - await page.getByLabel("Search query").fill("install"); - await installSearchResponse; - await expect(page.getByRole("heading", { name: "Results" })).toBeVisible(); - const quickstartLink = page.locator( - `section[aria-live="polite"] a[href="${QUICKSTART_INSTALL_HEADING_HREF}"]` - ); - await expect(quickstartLink).toBeVisible(); - await expect(quickstartLink).toContainText("Quickstart"); - - if (!answerConfig.enabled) { - await expect(page.getByText(AI_DISABLED_MESSAGE)).toBeVisible(); - } -}); - -test("search api returns JSON results", async ({ request }) => { - const response = await request.get("/api/docs/search?q=install"); - - expect(response.ok()).toBe(true); - const data = (await response.json()) as { - results: Array<{ title: string; urlPath: string }>; - }; - expect(data.results.length).toBeGreaterThan(0); - expect(data.results.some((result) => result.title === "Quickstart")).toBe( - true - ); -}); diff --git a/apps/docs-smoke/.gitignore b/apps/example/.gitignore similarity index 100% rename from apps/docs-smoke/.gitignore rename to apps/example/.gitignore diff --git a/apps/docs-smoke/components.json b/apps/example/components.json similarity index 100% rename from apps/docs-smoke/components.json rename to apps/example/components.json diff --git a/apps/docs-smoke/package.json b/apps/example/package.json similarity index 97% rename from apps/docs-smoke/package.json rename to apps/example/package.json index d81304e..440b5cc 100644 --- a/apps/docs-smoke/package.json +++ b/apps/example/package.json @@ -1,5 +1,5 @@ { - "name": "docs-smoke", + "name": "example", "version": "0.0.0", "private": true, "type": "module", @@ -56,6 +56,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.2.0", "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", "typescript": "5.9.2", "vite": "^7.3.1", "vite-tsconfig-paths": "^5.1.4" diff --git a/apps/docs-smoke/playwright.config.ts b/apps/example/playwright.config.ts similarity index 76% rename from apps/docs-smoke/playwright.config.ts rename to apps/example/playwright.config.ts index 47e0f0e..b269534 100644 --- a/apps/docs-smoke/playwright.config.ts +++ b/apps/example/playwright.config.ts @@ -6,7 +6,7 @@ const HTTPS_PROTOCOL = "https://"; const HTTP_PROTOCOL = "http://"; const DEFAULT_BASE_URL = "http://localhost:3000"; -function getDocsSmokeBaseUrl(): string { +function getExampleBaseUrl(): string { const configuredBaseUrl = process.env.PLAYWRIGHT_BASE_URL?.trim(); if (configuredBaseUrl) { return configuredBaseUrl; @@ -14,12 +14,12 @@ function getDocsSmokeBaseUrl(): string { let portlessUrl: string; try { - portlessUrl = execFileSync("portless", ["get", "docs-smoke"], { + portlessUrl = execFileSync("portless", ["get", "example"], { encoding: "utf8", }).trim(); } catch { process.stderr.write( - `Unable to resolve docs-smoke through portless. Falling back to ${DEFAULT_BASE_URL}. Set PLAYWRIGHT_BASE_URL to override this value.\n` + `Unable to resolve example through portless. Falling back to ${DEFAULT_BASE_URL}. Set PLAYWRIGHT_BASE_URL to override this value.\n` ); return DEFAULT_BASE_URL; } @@ -31,19 +31,19 @@ function getDocsSmokeBaseUrl(): string { : portlessUrl; } -const docsSmokeBaseUrl = getDocsSmokeBaseUrl(); +const exampleBaseUrl = getExampleBaseUrl(); export default defineConfig({ testDir: "./tests/e2e", testMatch: /.*\.e2e\.ts/, fullyParallel: true, use: { - baseURL: docsSmokeBaseUrl, + baseURL: exampleBaseUrl, trace: "on-first-retry", }, webServer: { command: "bun run dev", - url: docsSmokeBaseUrl, + url: exampleBaseUrl, reuseExistingServer: !isCI, }, projects: [ diff --git a/apps/docs-smoke/scripts/bench.ts b/apps/example/scripts/bench.ts similarity index 90% rename from apps/docs-smoke/scripts/bench.ts rename to apps/example/scripts/bench.ts index 53fb518..3d2903b 100644 --- a/apps/docs-smoke/scripts/bench.ts +++ b/apps/example/scripts/bench.ts @@ -10,15 +10,9 @@ import { existsSync } from "node:fs"; import { appendFile, readdir, rm } from "node:fs/promises"; import { join } from "node:path"; -import { convertAllMdx } from "../../../packages/docs/src/convert/index.ts"; -import { - generateLLMFullContextFiles, - generateLlmsTxt, -} from "../../../packages/docs/src/llm/index.ts"; -import { - defaultRemarkPlugins, - remarkInclude, -} from "../../../packages/docs/src/remark/index.ts"; +import { convertAllMdx } from "@inth/docs/convert"; +import { generateLLMFullContextFiles, generateLlmsTxt } from "@inth/docs/llm"; +import { defaultRemarkPlugins, remarkInclude } from "@inth/docs/remark"; const DEFAULT_RUNS = 3; const parsedRuns = Number.parseInt( @@ -97,14 +91,21 @@ async function bench(): Promise { summary: "Benchmark fixture.", bestStartingPoints: [], }, - docsSections: [ + groups: [ { + slug: "frameworks", title: "Frameworks", - links: [{ urlPath: "/docs/frameworks" }], + description: "Framework-specific guides.", }, { + slug: "integrations", title: "Integrations", - links: [{ urlPath: "/docs/integrations/overview" }], + description: "Integration guides.", + }, + { + slug: "self-host", + title: "Self-host", + description: "Self-hosting guides.", }, ], }); @@ -112,24 +113,21 @@ async function bench(): Promise { outDir: OUT_DIR, baseUrl: "https://docs.example.com", product: { name: "Bench SDK" }, - topics: [ + groups: [ { slug: "frameworks", title: "Frameworks", description: "Framework-specific guides.", - includePrefixes: ["frameworks/"], }, { slug: "integrations", title: "Integrations", description: "Integration guides.", - includePrefixes: ["integrations/"], }, { slug: "self-host", title: "Self-host", description: "Self-hosting guides.", - includePrefixes: ["self-host/"], }, ], }); diff --git a/apps/docs-smoke/scripts/convert-real.ts b/apps/example/scripts/convert-real.ts similarity index 78% rename from apps/docs-smoke/scripts/convert-real.ts rename to apps/example/scripts/convert-real.ts index f058056..3f2face 100644 --- a/apps/docs-smoke/scripts/convert-real.ts +++ b/apps/example/scripts/convert-real.ts @@ -6,11 +6,8 @@ import { rm } from "node:fs/promises"; import { join } from "node:path"; -import { convertAllMdx } from "../../../packages/docs/src/convert/index.ts"; -import { - defaultRemarkPlugins, - remarkInclude, -} from "../../../packages/docs/src/remark/index.ts"; +import { convertAllMdx } from "@inth/docs/convert"; +import { defaultRemarkPlugins, remarkInclude } from "@inth/docs/remark"; const FIXTURE_DIR = join(process.cwd(), "content-fixtures", "c15t"); const SRC_DIR = FIXTURE_DIR; diff --git a/apps/docs-smoke/scripts/llm-generate-real.ts b/apps/example/scripts/llm-generate-real.ts similarity index 71% rename from apps/docs-smoke/scripts/llm-generate-real.ts rename to apps/example/scripts/llm-generate-real.ts index 435fbed..2de49d2 100644 --- a/apps/docs-smoke/scripts/llm-generate-real.ts +++ b/apps/example/scripts/llm-generate-real.ts @@ -3,16 +3,18 @@ * Runs the llm generator against real c15t docs so we can inspect * /llms.txt and the nested /docs/llms-full/** tree. * - * The topic tree demonstrates the intended shape for any multi-surface SDK: + * The group tree demonstrates the intended shape for any multi-surface SDK: * agents pick a task-scoped leaf (e.g. `frameworks/react.txt`) instead of * downloading an entire monolithic `llms-full.txt` they'll mostly ignore. + * + * NOTE: c15t MDX doesn't currently declare `group:` in frontmatter, so the + * leaves render empty. Run `bun run scripts/inject-real-groups.ts` (TODO) + * to backfill frontmatter from top-level directories before this script + * for representative output. */ import { join } from "node:path"; -import { - generateLLMFullContextFiles, - generateLlmsTxt, -} from "../../../packages/docs/src/llm/index.ts"; +import { generateLLMFullContextFiles, generateLlmsTxt } from "@inth/docs/llm"; const FIXTURE_DIR = join(process.cwd(), "content-fixtures", "c15t"); const SRC_DIR = FIXTURE_DIR; @@ -33,56 +35,30 @@ await generateLlmsTxt({ agentGuidance: "Start with the framework guide that matches your stack, then consult the matching full-context file under /docs/llms-full/.", }, - docsSections: [ - { - title: "Frameworks", - description: "Framework integrations.", - links: [{ urlPath: "/docs/frameworks" }], - }, - { - title: "Self-host", - description: "Run c15t yourself.", - links: [{ urlPath: "/docs/self-host" }], - }, - { - title: "Integrations", - description: "Third-party integrations.", - links: [{ urlPath: "/docs/integrations/overview" }], - }, - ], -}); - -await generateLLMFullContextFiles({ - outDir: OUT_DIR, - baseUrl: "https://c15t.com", - product: { name: "c15t" }, - topics: [ + groups: [ { slug: "frameworks", title: "Frameworks", description: "Framework integrations. Pick the leaf that matches your stack.", - topics: [ + children: [ { slug: "react", title: "React", description: "React integration — hooks, components, client-mode configuration.", - includePrefixes: ["frameworks/react/"], }, { slug: "next", title: "Next.js", description: "Next.js integration — App Router, server-side rendering, geolocation.", - includePrefixes: ["frameworks/next/"], }, { slug: "javascript", title: "JavaScript", description: "Framework-agnostic vanilla JavaScript integration for any frontend stack.", - includePrefixes: ["frameworks/javascript/"], }, ], }, @@ -90,20 +66,18 @@ await generateLLMFullContextFiles({ slug: "self-host", title: "Self-host", description: "Self-host the c15t consent backend in your infrastructure.", - topics: [ + children: [ { slug: "guides", title: "Guides", description: "Self-hosting how-to guides — database, caching, edge, observability, policy packs.", - includePrefixes: ["self-host/guides/", "self-host/quickstart"], }, { slug: "api", title: "API Reference", description: "Backend configuration options and HTTP endpoint reference.", - includePrefixes: ["self-host/api/"], }, ], }, @@ -112,16 +86,51 @@ await generateLLMFullContextFiles({ title: "Integrations", description: "Third-party integrations — analytics, tag managers, ad pixels.", - includePrefixes: ["integrations/"], }, { slug: "concepts", title: "Concepts", description: "Framework-agnostic concepts — glossary, cookie management, consent model.", - includePrefixes: ["shared/concepts/"], }, ], }); +await generateLLMFullContextFiles({ + outDir: OUT_DIR, + baseUrl: "https://c15t.com", + product: { name: "c15t" }, + groups: [ + { + slug: "frameworks", + title: "Frameworks", + description: "Framework integrations.", + children: [ + { slug: "react", title: "React", description: "React integration." }, + { slug: "next", title: "Next.js", description: "Next.js integration." }, + { + slug: "javascript", + title: "JavaScript", + description: "Vanilla JS integration.", + }, + ], + }, + { + slug: "self-host", + title: "Self-host", + description: "Self-hosting context.", + children: [ + { slug: "guides", title: "Guides", description: "Self-host guides." }, + { slug: "api", title: "API", description: "Backend API reference." }, + ], + }, + { + slug: "integrations", + title: "Integrations", + description: "Third-party.", + }, + { slug: "concepts", title: "Concepts", description: "Shared concepts." }, + ], +}); + process.stdout.write("LLM files generated for real c15t content\n"); diff --git a/apps/example/scripts/llm-generate.ts b/apps/example/scripts/llm-generate.ts new file mode 100644 index 0000000..8b56328 --- /dev/null +++ b/apps/example/scripts/llm-generate.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env bun +/** + * Reads the package's `docs.config.ts` and `/docs` MDX source, then generates + * `/llms.txt`, `/docs/llms-full/*.txt`, and a `docs-nav.json` manifest into + * `apps/example/public/`. The config drives both the package's own published + * docs bundle and this example app's served bundle — same source, two + * consumers. + */ + +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + generateLLMFullContextFiles, + generateLlmsTxt, + resolveDocsNavigation, +} from "@inth/docs/llm"; +import docsConfig from "../../../docs/docs.config"; + +const scriptsRoot = dirname(fileURLToPath(import.meta.url)); +const appRoot = join(scriptsRoot, ".."); +const repoRoot = join(appRoot, "..", ".."); +// `generateLlmsTxt` joins `${srcDir}/docs/` and `${outDir}/docs/` internally, +// so we pass the parents (repo root for source, app root/public for output). +const srcDir = repoRoot; +const outDir = join(appRoot, "public"); +const generatedDir = join(appRoot, "src", "generated"); +const baseUrl = + process.env.INTH_DOCS_AGENT_BASE_URL?.trim() || + process.env.BASE_URL?.trim() || + process.env.PORTLESS_URL?.trim() || + "https://docs.example.com"; + +await generateLlmsTxt({ + srcDir, + outDir, + baseUrl, + product: docsConfig.product, + groups: docsConfig.groups, +}); + +await generateLLMFullContextFiles({ + outDir, + baseUrl, + product: { name: docsConfig.product.name }, + groups: docsConfig.groups, +}); + +// Build the runtime sidebar manifest. Doing this in the build pipeline keeps +// the docs.config.ts as the single source of truth: the same call resolves +// frontmatter membership for the LLM bundles AND for the in-app sidebar. +const navigation = await resolveDocsNavigation({ + srcDir, + baseUrl, + groups: docsConfig.groups, +}); + +if (navigation.unknown.length > 0) { + for (const { urlPath, slug } of navigation.unknown) { + process.stderr.write( + `error: ${urlPath} declares unknown group "${slug}".\n` + ); + } + process.exit(1); +} + +await mkdir(generatedDir, { recursive: true }); +await writeFile( + join(generatedDir, "docs-nav.json"), + `${JSON.stringify(navigation, null, 2)}\n` +); + +process.stdout.write("LLM files + nav manifest generated\n"); diff --git a/apps/docs-smoke/scripts/mdx-convert.ts b/apps/example/scripts/mdx-convert.ts similarity index 68% rename from apps/docs-smoke/scripts/mdx-convert.ts rename to apps/example/scripts/mdx-convert.ts index 0591b2a..3358f0a 100644 --- a/apps/docs-smoke/scripts/mdx-convert.ts +++ b/apps/example/scripts/mdx-convert.ts @@ -1,27 +1,25 @@ #!/usr/bin/env bun /** - * Mirrors ~/dev/monorepo/apps/dsar-docs/scripts/tasks/mdx-convert.ts to verify - * @inth/docs is a drop-in replacement for @inth/optin-docs. + * Reads the package's MDX source at the repo root `/docs` and writes converted + * markdown into `apps/example/public/docs` for the dev server. This is exactly + * what an external consumer (e.g. c15t/c15t) would do with their own docs. */ import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { - convertAllMdx, - type MdxToMarkdownOptions, -} from "../../../packages/docs/src/convert/index.ts"; +import { convertAllMdx, type MdxToMarkdownOptions } from "@inth/docs/convert"; import { defaultRemarkPlugins, remarkInclude, remarkTypeTableToMarkdown, -} from "../../../packages/docs/src/remark/index.ts"; +} from "@inth/docs/remark"; const scriptsRoot = dirname(fileURLToPath(import.meta.url)); -const repoRoot = join(scriptsRoot, "..", ".."); const appRoot = join(scriptsRoot, ".."); -const srcDir = join(appRoot, "content"); -const outDir = join(appRoot, "public"); +const repoRoot = join(appRoot, "..", ".."); +const srcDir = join(repoRoot, "docs"); +const outDir = join(appRoot, "public", "docs"); const typeTableRemarkPlugin: NonNullable< MdxToMarkdownOptions["remarkPlugins"] >[number] = [remarkTypeTableToMarkdown, { basePath: repoRoot }]; diff --git a/apps/docs-smoke/scripts/search-generate.ts b/apps/example/scripts/search-generate.ts similarity index 92% rename from apps/docs-smoke/scripts/search-generate.ts rename to apps/example/scripts/search-generate.ts index 2354a8c..ea51f5b 100644 --- a/apps/docs-smoke/scripts/search-generate.ts +++ b/apps/example/scripts/search-generate.ts @@ -6,7 +6,7 @@ import { copyFile, mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { generateDocsSearchFiles } from "../../../packages/docs/src/search/node-index.ts"; +import { generateDocsSearchFiles } from "@inth/docs/search/node"; const scriptsRoot = dirname(fileURLToPath(import.meta.url)); const appRoot = join(scriptsRoot, ".."); diff --git a/apps/docs-smoke/scripts/setup-real-content.ts b/apps/example/scripts/setup-real-content.ts similarity index 100% rename from apps/docs-smoke/scripts/setup-real-content.ts rename to apps/example/scripts/setup-real-content.ts diff --git a/apps/docs-smoke/scripts/test-pipeline.ts b/apps/example/scripts/test-pipeline.ts similarity index 89% rename from apps/docs-smoke/scripts/test-pipeline.ts rename to apps/example/scripts/test-pipeline.ts index b7e65a1..2703bef 100644 --- a/apps/docs-smoke/scripts/test-pipeline.ts +++ b/apps/example/scripts/test-pipeline.ts @@ -1,11 +1,11 @@ #!/usr/bin/env bun import { join } from "node:path"; -import { convertMdxToMarkdown } from "../../../packages/docs/src/convert/index.ts"; +import { convertMdxToMarkdown } from "@inth/docs/convert"; import { defaultRemarkPlugins, remarkTypeTableToMarkdown, -} from "../../../packages/docs/src/remark/index.ts"; +} from "@inth/docs/remark"; const appRoot = process.cwd(); const repoRoot = join(appRoot, "..", ".."); diff --git a/apps/docs-smoke/scripts/test-real.ts b/apps/example/scripts/test-real.ts similarity index 91% rename from apps/docs-smoke/scripts/test-real.ts rename to apps/example/scripts/test-real.ts index fc01ce5..7b9b2cf 100644 --- a/apps/docs-smoke/scripts/test-real.ts +++ b/apps/example/scripts/test-real.ts @@ -9,12 +9,9 @@ import { existsSync } from "node:fs"; import { readdir, rm } from "node:fs/promises"; import { join } from "node:path"; -import { convertAllMdx } from "../../../packages/docs/src/convert/index.ts"; -import { lintDocs } from "../../../packages/docs/src/lint/index.ts"; -import { - defaultRemarkPlugins, - remarkInclude, -} from "../../../packages/docs/src/remark/index.ts"; +import { convertAllMdx } from "@inth/docs/convert"; +import { lintDocs } from "@inth/docs/lint"; +import { defaultRemarkPlugins, remarkInclude } from "@inth/docs/remark"; const FIXTURE_DIR = join(process.cwd(), "content-fixtures", "c15t"); const SRC_DIR = join(FIXTURE_DIR, "docs"); diff --git a/apps/docs-smoke/src/components/component-matrix.tsx b/apps/example/src/components/component-matrix.tsx similarity index 100% rename from apps/docs-smoke/src/components/component-matrix.tsx rename to apps/example/src/components/component-matrix.tsx diff --git a/apps/docs-smoke/src/components/docs-mdx/accordion.tsx b/apps/example/src/components/docs-mdx/accordion.tsx similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/accordion.tsx rename to apps/example/src/components/docs-mdx/accordion.tsx diff --git a/apps/docs-smoke/src/components/docs-mdx/callout.tsx b/apps/example/src/components/docs-mdx/callout.tsx similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/callout.tsx rename to apps/example/src/components/docs-mdx/callout.tsx diff --git a/apps/docs-smoke/src/components/docs-mdx/card.tsx b/apps/example/src/components/docs-mdx/card.tsx similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/card.tsx rename to apps/example/src/components/docs-mdx/card.tsx diff --git a/apps/docs-smoke/src/components/docs-mdx/command-tabs.tsx b/apps/example/src/components/docs-mdx/command-tabs.tsx similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/command-tabs.tsx rename to apps/example/src/components/docs-mdx/command-tabs.tsx diff --git a/apps/docs-smoke/src/components/docs-mdx/example.tsx b/apps/example/src/components/docs-mdx/example.tsx similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/example.tsx rename to apps/example/src/components/docs-mdx/example.tsx diff --git a/apps/docs-smoke/src/components/docs-mdx/index.ts b/apps/example/src/components/docs-mdx/index.ts similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/index.ts rename to apps/example/src/components/docs-mdx/index.ts diff --git a/apps/docs-smoke/src/components/docs-mdx/mdx-components.ts b/apps/example/src/components/docs-mdx/mdx-components.ts similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/mdx-components.ts rename to apps/example/src/components/docs-mdx/mdx-components.ts diff --git a/apps/docs-smoke/src/components/docs-mdx/mermaid.tsx b/apps/example/src/components/docs-mdx/mermaid.tsx similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/mermaid.tsx rename to apps/example/src/components/docs-mdx/mermaid.tsx diff --git a/apps/docs-smoke/src/components/docs-mdx/selector.tsx b/apps/example/src/components/docs-mdx/selector.tsx similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/selector.tsx rename to apps/example/src/components/docs-mdx/selector.tsx diff --git a/apps/docs-smoke/src/components/docs-mdx/steps.tsx b/apps/example/src/components/docs-mdx/steps.tsx similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/steps.tsx rename to apps/example/src/components/docs-mdx/steps.tsx diff --git a/apps/docs-smoke/src/components/docs-mdx/tabs.tsx b/apps/example/src/components/docs-mdx/tabs.tsx similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/tabs.tsx rename to apps/example/src/components/docs-mdx/tabs.tsx diff --git a/apps/docs-smoke/src/components/docs-mdx/topic-switcher.tsx b/apps/example/src/components/docs-mdx/topic-switcher.tsx similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/topic-switcher.tsx rename to apps/example/src/components/docs-mdx/topic-switcher.tsx diff --git a/apps/docs-smoke/src/components/docs-mdx/type-table.tsx b/apps/example/src/components/docs-mdx/type-table.tsx similarity index 100% rename from apps/docs-smoke/src/components/docs-mdx/type-table.tsx rename to apps/example/src/components/docs-mdx/type-table.tsx diff --git a/apps/example/src/components/docs-shell.tsx b/apps/example/src/components/docs-shell.tsx new file mode 100644 index 0000000..942b8a6 --- /dev/null +++ b/apps/example/src/components/docs-shell.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Link, useRouterState } from "@tanstack/react-router"; +import type { ReactNode } from "react"; +import { docsSidebarSections } from "@/lib/docs"; +import { cn } from "@/lib/utils"; +import { SiteFooter } from "./site-footer"; +import { SiteHeader } from "./site-header"; + +export function DocsShell({ children }: { children: ReactNode }) { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + + return ( +
+ +
+ +
+
+ {children} +
+
+
+ +
+ ); +} diff --git a/apps/example/src/components/search-bar.tsx b/apps/example/src/components/search-bar.tsx new file mode 100644 index 0000000..d5abebf --- /dev/null +++ b/apps/example/src/components/search-bar.tsx @@ -0,0 +1,409 @@ +"use client"; + +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { + type KeyboardEvent, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import { Streamdown } from "streamdown"; +import { SEARCH_MAX_QUERY_LENGTH, useDocsSearch } from "@/lib/use-docs-search"; +import { cn } from "@/lib/utils"; + +const MAX_RESULTS = 6; + +const EXAMPLE_AI_QUESTIONS = [ + "How do I generate llms.txt for my docs?", + "Which MDX components are available?", + "How does AI search work in this site?", + "How do I lint and convert my docs?", +]; + +/** + * Cmd+K (or Ctrl+K) docs search popover. Reuses `useDocsSearch` so the popover + * and the standalone /search page share one state machine. Shows live results + * keyed by arrow keys, navigates to the chosen doc on Enter, and exposes the + * AI answer flow inline via the same `/api/docs/ask` SSE the /search page uses. + */ +export function SearchBar() { + const inputId = useId(); + const navigate = useNavigate(); + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + const triggerRef = useRef(null); + const inputRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const search = useDocsSearch(); + const { + answer, + answerConfig, + answerStatus, + askAi, + cancel, + error, + query, + reset, + results, + searchStatus, + setQuery, + } = search; + + const open = useCallback(() => { + setIsOpen(true); + setActiveIndex(0); + }, []); + + const close = useCallback(() => { + cancel(); + setIsOpen(false); + triggerRef.current?.focus(); + }, [cancel]); + + // Global Cmd+K / Ctrl+K shortcut. Toggles the popover from anywhere in the + // app — the modern docs-site keyboard convention (Algolia, Mintlify, etc.). + useEffect(() => { + function onKeyDown(event: globalThis.KeyboardEvent) { + const isToggle = + (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k"; + if (isToggle) { + event.preventDefault(); + setIsOpen((current) => !current); + return; + } + if (event.key === "Escape" && isOpen) { + event.preventDefault(); + close(); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [close, isOpen]); + + // Autofocus the input on open; reset state on close. + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + return; + } + reset(); + }, [isOpen, reset]); + + // Close popover when navigation happens (e.g. user clicked a result). + const lastPathRef = useRef(pathname); + useEffect(() => { + if (lastPathRef.current !== pathname && isOpen) { + setIsOpen(false); + } + lastPathRef.current = pathname; + }, [isOpen, pathname]); + + const visibleResults = useMemo( + () => results.slice(0, MAX_RESULTS), + [results] + ); + + // Reset selection when results change. + useEffect(() => { + setActiveIndex(0); + }, []); + + function handleInputKeyDown(event: KeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((index) => + Math.min(index + 1, Math.max(visibleResults.length - 1, 0)) + ); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((index) => Math.max(index - 1, 0)); + return; + } + if (event.key === "Enter") { + if (event.metaKey || event.ctrlKey) { + event.preventDefault(); + const trimmed = query.trim(); + if (trimmed) { + navigate({ to: "/search", search: { q: trimmed } }); + setIsOpen(false); + } + return; + } + const result = visibleResults[activeIndex]; + if (result) { + event.preventDefault(); + navigate({ to: result.urlPath, hash: result.anchor || undefined }); + } + } + } + + const showEmptyState = !query.trim() && results.length === 0; + const isAnswering = + answerStatus === "loading" || answerStatus === "streaming"; + const canAsk = query.trim().length > 0 && answerConfig.enabled; + + return ( + <> + + + {isOpen ? ( +
+ +
+ +
+ {showEmptyState ? ( + { + askAi(question).catch(() => undefined); + }} + showAiExamples={answerConfig.enabled} + /> + ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + {visibleResults.length > 0 ? ( + + ) : null} + + {answer || isAnswering ? ( +
+
+

+ Answer +

+ + {answerConfig.model} + +
+ + {answer || "Streaming…"} + +
+ ) : null} +
+ +
+
+ + + ↑↓ + {" "} + navigate + + + + ↵ + {" "} + open + +
+ +
+ + + ) : null} + + ); +} + +function EmptyState({ + onAskExample, + showAiExamples, +}: { + onAskExample: (question: string) => void; + showAiExamples: boolean; +}) { + return ( +
+

+ Type a docs term such as convert, llms.txt, or{" "} + lint. Press{" "} + + ⌘↵ + {" "} + to open the full search page. +

+ {showAiExamples ? ( +
+

+ Ask the AI +

+
    + {EXAMPLE_AI_QUESTIONS.map((question) => ( +
  • + +
  • + ))} +
+
+ ) : null} +
+ ); +} + +function SearchIcon() { + return ( + + ); +} + +function SparkleIcon() { + return ( + + ); +} diff --git a/apps/example/src/components/site-footer.tsx b/apps/example/src/components/site-footer.tsx new file mode 100644 index 0000000..87bcbae --- /dev/null +++ b/apps/example/src/components/site-footer.tsx @@ -0,0 +1,40 @@ +const footerLinks = [ + { + href: "https://github.com/inthhq/docs", + label: "GitHub", + }, + { + href: "https://www.npmjs.com/package/@inth/docs", + label: "npm", + }, + { + href: "/llms.txt", + label: "llms.txt", + }, +] as const; + +export function SiteFooter() { + return ( +
+
+

@inth/docs reference app

+ +
+
+ ); +} diff --git a/apps/example/src/components/site-header.tsx b/apps/example/src/components/site-header.tsx new file mode 100644 index 0000000..8a056e7 --- /dev/null +++ b/apps/example/src/components/site-header.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Link, useRouterState } from "@tanstack/react-router"; +import { navigationRoutes } from "@/lib/docs"; +import { cn } from "@/lib/utils"; +import { SearchBar } from "./search-bar"; + +export function SiteHeader() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + + return ( +
+
+ + @inth/docs + +
+ + +
+
+
+ ); +} diff --git a/apps/docs-smoke/src/components/ui/badge.tsx b/apps/example/src/components/ui/badge.tsx similarity index 100% rename from apps/docs-smoke/src/components/ui/badge.tsx rename to apps/example/src/components/ui/badge.tsx diff --git a/apps/docs-smoke/src/components/ui/button.tsx b/apps/example/src/components/ui/button.tsx similarity index 100% rename from apps/docs-smoke/src/components/ui/button.tsx rename to apps/example/src/components/ui/button.tsx diff --git a/apps/docs-smoke/src/components/ui/card.tsx b/apps/example/src/components/ui/card.tsx similarity index 100% rename from apps/docs-smoke/src/components/ui/card.tsx rename to apps/example/src/components/ui/card.tsx diff --git a/apps/docs-smoke/src/components/ui/separator.tsx b/apps/example/src/components/ui/separator.tsx similarity index 100% rename from apps/docs-smoke/src/components/ui/separator.tsx rename to apps/example/src/components/ui/separator.tsx diff --git a/apps/example/src/generated/docs-nav.json b/apps/example/src/generated/docs-nav.json new file mode 100644 index 0000000..6a48c95 --- /dev/null +++ b/apps/example/src/generated/docs-nav.json @@ -0,0 +1,156 @@ +{ + "groups": [ + { + "slug": "overview", + "segmentPath": ["overview"], + "title": "Overview", + "description": "Start here for package scope and surface selection.", + "pages": [ + { + "urlPath": "/docs", + "title": "@inth/docs", + "description": "Reference map for the shared MDX conversion, linting, and LLM doc-generation package.", + "groups": ["overview"] + }, + { + "urlPath": "/docs/methodology", + "title": "Methodology", + "description": "How @inth/docs differs from Fumadocs, Mintlify, and Starlight", + "groups": ["overview"] + } + ], + "children": [] + }, + { + "slug": "guides", + "segmentPath": ["guides"], + "title": "Guides", + "description": "Practical ways to wire @inth/docs into docs apps.", + "pages": [ + { + "urlPath": "/docs/guides/bundle-package-docs", + "title": "Bundle docs into a package", + "description": "How to ship generated agent docs inside an npm package.", + "groups": ["guides"] + }, + { + "urlPath": "/docs/guides/connect-docs-site", + "title": "Connect a docs site", + "description": "How to run the @inth/docs pipeline before building a docs app.", + "groups": ["guides"] + } + ], + "children": [] + }, + { + "slug": "authoring", + "segmentPath": ["authoring"], + "title": "Authoring And Rendering", + "description": "React MDX components and remark pipeline behavior.", + "pages": [], + "children": [ + { + "slug": "components", + "segmentPath": ["authoring", "components"], + "title": "Components", + "description": "React MDX component adapters.", + "pages": [ + { + "urlPath": "/docs/components", + "title": "Components", + "description": "How to define app-owned MDX components that the @inth/docs pipeline can flatten.", + "groups": ["components"] + } + ], + "children": [] + }, + { + "slug": "remark", + "segmentPath": ["authoring", "remark"], + "title": "Remark", + "description": "Default plugins and conversion helpers.", + "pages": [ + { + "urlPath": "/docs/remark", + "title": "Remark", + "description": "Reference for the remark plugins and default plugin pipeline exported by @inth/docs.", + "groups": ["remark"] + } + ], + "children": [] + } + ] + }, + { + "slug": "generation", + "segmentPath": ["generation"], + "title": "Generation", + "description": "MDX conversion, LLM output generation, and search.", + "pages": [], + "children": [ + { + "slug": "convert", + "segmentPath": ["generation", "convert"], + "title": "Convert", + "description": "MDX-to-markdown conversion APIs.", + "pages": [], + "children": [] + }, + { + "slug": "llm", + "segmentPath": ["generation", "llm"], + "title": "LLM", + "description": "Summary and full-context file generation.", + "pages": [ + { + "urlPath": "/docs/llm", + "title": "LLM", + "description": "How to generate llms.txt and topic-scoped full-context files from @inth/docs.", + "groups": ["llm"] + } + ], + "children": [] + }, + { + "slug": "search", + "segmentPath": ["generation", "search"], + "title": "Search", + "description": "Static search indexes and AI answer helpers.", + "pages": [ + { + "urlPath": "/docs/search", + "title": "Search", + "description": "Generate and query a static docs search index, then stream source-grounded AI answers.", + "groups": ["search"] + } + ], + "children": [] + } + ] + }, + { + "slug": "validation", + "segmentPath": ["validation"], + "title": "Validation", + "description": "Content validation and link checks.", + "pages": [ + { + "urlPath": "/docs/lint", + "title": "Lint", + "description": "How to validate docs content with lintDocs and the @inth/docs lint CLI.", + "groups": ["validation"] + } + ], + "children": [] + } + ], + "ungrouped": [ + { + "urlPath": "/docs/convert", + "title": "Convert", + "description": "How to convert MDX docs into Markdown with @inth/docs/convert.", + "groups": [] + } + ], + "unknown": [] +} diff --git a/apps/example/src/generated/docs-search-content.json b/apps/example/src/generated/docs-search-content.json new file mode 100644 index 0000000..55858db --- /dev/null +++ b/apps/example/src/generated/docs-search-content.json @@ -0,0 +1 @@ +{"version":2,"generatedAt":"2026-04-28T23:09:12.077Z","chunks":["Components\n\nHow to define app-owned MDX components that the @inth/docs pipeline can flatten.\n\nComponents\n\n@inth/docs does not export prebuilt UI components or a mdxComponents map. The consuming docs app owns runtime rendering, styling, accessibility, and framework-specific integration. The package only assumes a small authoring contract for MDX component names so the remark pipeline can flatten those components into Markdown for agents, LLM bundles, search indexes, and validation.","Components\n\nHow to define app-owned MDX components that the @inth/docs pipeline can flatten.\n\nComponents\n\nApp-Owned Adapter Map\n\nDefine your own mdxComponents map in the docs app. The example app keeps its implementation under apps/example/src/components/docs-mdx . Common component names handled by the default remark pipeline include Accordion AccordionItem ExtractedTypeTable Callout Card Cards Example Mermaid CommandTabs Selector Step Steps Tab Tabs TopicSwitcher TypeTable Use it like this If your app uses different names, add custom remark plugins or adapter components that map authored MDX back to the names handled by the pipeline.\n\n```tsx import { mdxComponents } from \"@/components/docs-mdx\"; const components = { ...mdxComponents, }; ```","Components\n\nHow to define app-owned MDX components that the @inth/docs pipeline can flatten.\n\nComponents\n\nImportant Components\n\nAccordion and AccordionItem\n\nUse for secondary details that should be collapsible in the browser but still available to markdown conversion, search, and LLM bundles. Closed content is still flattened by the remark pipeline, so do not use accordions to hide content from generated outputs.\n\n```tsx Use accordions for supporting details, troubleshooting notes, and optional reference material. ```","Components\n\nHow to define app-owned MDX components that the @inth/docs pipeline can flatten.\n\nComponents\n\nImportant Components\n\nCommandTabs\n\nUse for package-manager-specific install or run commands. Use mode=\"install\" when command is a package name, mode=\"run\" when command is a CLI name, and mode=\"create\" for starter commands such as pnpm create next-app . command can also include a pm placeholder for custom templates. Use commands for exact per-manager overrides and defaultManager to choose the initial tab.\n\n```tsx ```","Components\n\nHow to define app-owned MDX components that the @inth/docs pipeline can flatten.\n\nComponents\n\nImportant Components\n\nExample\n\nUse for data-driven preview and source examples. The app-owned component receives code as data; host apps can add filesystem loaders or dynamic imports outside @inth/docs when they need app-specific behavior. Use sourceFiles when an example needs to show supporting files in addition to the primary snippet.\n\n```tsx The host app owns styling and runtime components while `@inth/docs` owns conversion. ```","Components\n\nHow to define app-owned MDX components that the @inth/docs pipeline can flatten.\n\nComponents\n\nImportant Components\n\nTypeTable and ExtractedTypeTable\n\nUse TypeTable for explicit prop or type rows you already know. Use ExtractedTypeTable when the docs should extract types from source files. ExtractedTypeTable is the most path-sensitive component in the set. If it needs to resolve project files, pair it with the matching remark plugin configuration and set a stable base path.","Components\n\nHow to define app-owned MDX components that the @inth/docs pipeline can flatten.\n\nComponents\n\nImportant Components\n\nTabs , Tab , Steps , Step\n\nThese component names are primarily authoring affordances in MDX. When the markdown conversion pipeline runs, their content is flattened into standard markdown so agents do not need JSX-aware renderers.","Components\n\nHow to define app-owned MDX components that the @inth/docs pipeline can flatten.\n\nComponents\n\nImportant Components\n\nTopicSwitcher\n\nUse for reader-facing navigation across equivalent documentation topics. TopicSwitcher is intentionally generic. Frameworks are one topic category, but the same component can represent SDKs, runtimes, deployment targets, product areas, or other equivalent slices of docs. It does not automatically read LLM topic config.\n\n```tsx ```","Components\n\nHow to define app-owned MDX components that the @inth/docs pipeline can flatten.\n\nComponents\n\nGuidance\n\nKeep runtime components in the docs app. Keep component names stable when the conversion pipeline depends on them. If the goal is agent-readable markdown, read Remark instead of reimplementing the JSX flattening rules.","Convert\n\nHow to convert MDX docs into Markdown with @inth/docs/convert.\n\nConvert\n\nThe @inth/docs/convert entrypoint provides three main APIs convertMdxToMarkdown writeMdxFileAsMarkdown convertAllMdx Import them from\n\n```ts import { convertAllMdx, convertMdxToMarkdown, writeMdxFileAsMarkdown, } from \"@inth/docs/convert\"; ```","Convert\n\nHow to convert MDX docs into Markdown with @inth/docs/convert.\n\nConvert\n\nMain Use Cases\n\nConvert one file in memory\n\nUse convertMdxToMarkdown when you need the rendered markdown string plus the resolved frontmatter.\n\n```ts const result = await convertMdxToMarkdown( \"docs/guides/quickstart.mdx\", defaultRemarkPlugins, false ); ```","Convert\n\nHow to convert MDX docs into Markdown with @inth/docs/convert.\n\nConvert\n\nMain Use Cases\n\nConvert a single file to disk\n\nUse writeMdxFileAsMarkdown when you already know the source path and output path.","Convert\n\nHow to convert MDX docs into Markdown with @inth/docs/convert.\n\nConvert\n\nMain Use Cases\n\nConvert an entire docs tree\n\nUse convertAllMdx for batch conversion\n\n```ts await convertAllMdx({ srcDir: \"content\", outDir: \"public\", remarkPlugins: defaultRemarkPlugins, enrichFrontmatterFromGit: true, }); ```","Convert\n\nHow to convert MDX docs into Markdown with @inth/docs/convert.\n\nConvert\n\nImportant Config\n\nsrcDir root directory containing .mdx files. outDir destination for generated .md files. remarkPlugins additional unified plugins, usually defaultRemarkPlugins from @inth/docs/remark . enrichFrontmatterFromGit adds git-derived metadata when available.","Convert\n\nHow to convert MDX docs into Markdown with @inth/docs/convert.\n\nConvert\n\nBehavior Notes\n\nFrontmatter is preserved when present. If a file has no frontmatter, the converter synthesizes title and sometimes description from the rendered markdown. Markdown tables and Mermaid blocks are compacted after rendering for cleaner agent consumption. Conversion is concurrent and optimized for large doc trees.","Convert\n\nHow to convert MDX docs into Markdown with @inth/docs/convert.\n\nConvert\n\nRecommended Pairing\n\nIn most apps, pair conversion with Then pass Use remarkInclude only when the source docs actually rely on include tags or partial expansion.\n\n```ts import { defaultRemarkPlugins, remarkInclude } from \"@inth/docs/remark\"; ``` ```ts remarkPlugins: [remarkInclude, ...defaultRemarkPlugins] ```","Bundle docs into a package\n\nHow to ship generated agent docs inside an npm package.\n\nBundle docs into a package\n\nUse this pattern when a package should carry its own agent-readable docs. The website is for humans. The package-bundled docs are for agents, CLIs, IDEs, and offline tooling that need a small reference set without loading the full docs site.","Bundle docs into a package\n\nHow to ship generated agent docs inside an npm package.\n\nBundle docs into a package\n\nGenerate into the package\n\nIf source docs live at the repo root in docs/ , generate package docs into the package directory This writes converted markdown, llms.txt , full-context files, and search artifacts under packages/my-package/docs .\n\n```bash npx @inth/docs generate \\ --src . \\ --out packages/my-package \\ --base-url https://docs.example.com/my-package \\ --name \"my-package\" \\ --summary \"Docs for my-package.\" \\ --format json ```","Bundle docs into a package\n\nHow to ship generated agent docs inside an npm package.\n\nBundle docs into a package\n\nFilter package-specific docs\n\nFor package-specific bundles, include only the docs paths that belong to that package Filters are explicit. If a package also needs shared or overview pages, include those paths too Use --include more than once for multiple paths. Use --exclude to remove private or internal docs after includes are applied.\n\n```bash npx @inth/docs generate \\ --src . \\ --out packages/react \\ --name \"@c15t/react\" \\ --summary \"React bindings for c15t.\" \\ --include \"frameworks/react/**\" \\ --format json ``` ```bash npx @inth/docs generate \\ --src . \\ --out packages/react \\ --include \"frameworks/react/**\" \\ --include \"shared/**\" \\ --format json ```","Bundle docs into a package\n\nHow to ship generated agent docs inside an npm package.\n\nBundle docs into a package\n\nInclude docs in the package\n\nAdd docs to the package's published files and run generation as part of the package build or prepack step This is the shape @inth/docs uses for its own package packages/docs/package.json includes docs in files , and the package build regenerates the docs bundle before publish.\n\n```json { \"files\": [\"dist\", \"docs\", \"README.md\"], \"scripts\": { \"build\": \"tsup && npx @inth/docs generate --src ../.. --out . --format json\" } } ```","Bundle docs into a package\n\nHow to ship generated agent docs inside an npm package.\n\nBundle docs into a package\n\nVerify before publishing\n\nRun the generator and inspect the package tarball The dry run should include docs/llms.txt docs/llms-full.txt docs/ / .md docs/search-index.json docs/search-content.json\n\n```bash npx @inth/docs generate --src . --out packages/my-package --format json npm pack --dry-run ```","Bundle docs into a package\n\nHow to ship generated agent docs inside an npm package.\n\nBundle docs into a package\n\nWhen to use this\n\nUse this when agents should understand the package from the installed dependency itself. If the goal is only to power a public docs website, use Connect a docs site instead.","Connect a docs site\n\nHow to run the @inth/docs pipeline before building a docs app.\n\nConnect a docs site\n\nUse this pattern when the docs app lives separately from the package repos it documents. The docs app owns the UI. Each package repo owns its MDX content. Before the docs app builds, clone the source repos and run the @inth/docs pipeline over their docs/ folders. If the package should also ship docs for agents, see Bundle docs into a package. For a local repo, run the pipeline directly\n\n```bash npx @inth/docs generate \\ --src . \\ --out public \\ --base-url https://docs.example.com/@inth/docs \\ --name \"@inth/docs\" \\ --summary \"Shared MDX conversion, linting, and LLM-doc generation package.\" \\ --format json ```","Connect a docs site\n\nHow to run the @inth/docs pipeline before building a docs app.\n\nConnect a docs site\n\nConfig setup\n\nUse docs.config.ts when the source repo should own product metadata and the docs group tree used by generation and navigation. The config describes product metadata and the group structure. Individual pages join groups from their MDX frontmatter Do not duplicate per-page membership in docs.config.ts . The generator reads group from source frontmatter and uses the config tree to name and organize the generated llms.txt , full-context files, and runtime navigation data.\n\n```ts import { defineDocsConfig } from \"@inth/docs\"; export default defineDocsConfig({ product: { name: \"@inth/docs\", summary: \"Shared MDX conversion, linting, and LLM-doc generation package.\", bullets: [ \"Flattens MDX-heavy docs into clean markdown for agents.\", \"Generates llms.txt and topic-scoped full-context bundles.\", ], bestStartingPoints: [{ urlPath: \"/docs\" }], }, groups: [ { slug: \"overview\", title: \"Overview\", description: \"Start here for package scope and surface selection.\", }, { slug: \"guides\", title: \"Guides\", description: \"Practical integration guides.\", }, ], }); ``` ```mdx --- title: \"Connect a docs site\" description: \"How to run the @inth/docs pipeline before building a docs app.\" group: guides --- ```","Connect a docs site\n\nHow to run the @inth/docs pipeline before building a docs app.\n\nConnect a docs site\n\nBuild flow\n\n1. Clone the source repo into a temporary build directory. 2. Run @inth/docs lint against the source docs. 3. Run @inth/docs generate against the clone. 4. Build the docs app from the generated markdown, llms.txt , full-context files, and search artifacts. This is the same shape we expect for repos like c15t/c15t source docs live in a public repo, while the rendered docs UI can be owned by another app or private template. Use --format json in CI or agent workflows. The command prints generated file paths, inferred groups, product metadata, and search index stats as JSON so automation can verify the result without scraping text.\n\n```bash git clone --depth 1 https://github.com/acme/package-a .docs-src/package-a npx @inth/docs lint .docs-src/package-a/docs --format json --error-unknown npx @inth/docs generate --src .docs-src/package-a --out public/package-a npm run build ```","Connect a docs site\n\nHow to run the @inth/docs pipeline before building a docs app.\n\nConnect a docs site\n\nGenerated output\n\nThe command writes public/docs/ / .md public/llms.txt public/docs/llms.txt public/docs/llms-full.txt public/docs/llms-full/ .txt public/docs/search-index.json public/docs/search-content.json","Connect a docs site\n\nHow to run the @inth/docs pipeline before building a docs app.\n\nConnect a docs site\n\nWhen to use this\n\nUse this when you want one docs UI for many repos, but do not want every package repo to own the same website implementation. If the docs source already lives inside the docs app repo, skip the clone step and point the pipeline at the local docs/ directory.","@inth/docs\n\nReference map for the shared MDX conversion, linting, and LLM doc-generation package.\n\n@inth/docs\n\n@inth/docs is the shared docs package for Inth properties. It provides A remark pipeline that flattens MDX components into LLM-friendly markdown. MDX to markdown conversion utilities. llms.txt and topic-scoped llms-full/ .txt generators. Static docs search, content readers, source-grounded answer helpers, and optional bash-tool integration. MDX linting utilities for frontmatter, meta.json , and docs links.","@inth/docs\n\nReference map for the shared MDX conversion, linting, and LLM doc-generation package.\n\n@inth/docs\n\nPackage Surfaces\n\nComponents The app-owned MDX component contract used by the remark pipeline. Convert convertMdxToMarkdown , writeMdxFileAsMarkdown , and convertAllMdx . Remark individual remark plugins plus defaultRemarkPlugins . LLM generateLlmsTxt and generateLLMFullContextFiles . Search generateDocsSearchFiles , searchDocs , source-grounded answer helpers, and the optional bash adapter. Lint lintDocs and the @inth/docs lint CLI.","@inth/docs\n\nReference map for the shared MDX conversion, linting, and LLM doc-generation package.\n\n@inth/docs\n\nWhen To Read Which Page\n\nReach for Components when defining app-owned MDX components for authored docs. Use Convert when you need markdown output from .mdx files. Check Remark for custom plugin order or component flattening behavior. Open LLM when generating llms.txt or topic-scoped full-context bundles. See Search for static indexing, runtime querying, or grounded answer streaming. Use Lint to validate frontmatter, docs URLs, or sidebar metadata.","@inth/docs\n\nReference map for the shared MDX conversion, linting, and LLM doc-generation package.\n\n@inth/docs\n\nOther Frameworks\n\nReact, Vue, Nuxt, Svelte, Astro, and other stacks can use the conversion, LLM, lint, and search entry points today. Runtime MDX components belong in the consuming docs app, not this package.","Lint\n\nHow to validate docs content with lintDocs and the @inth/docs lint CLI.\n\nLint\n\nThe lint surface validates source docs before conversion or site build. Use it in CI when a docs PR changes frontmatter, sidebar metadata, or internal docs links. Run the CLI with a source docs directory For agent or CI workflows, use JSON output and fail on unknown fields Import the library API from\n\n```bash npx @inth/docs lint docs ``` ```bash npx @inth/docs lint docs --format json --error-unknown --max-warnings 0 ``` ```ts import { lintDocs } from \"@inth/docs/lint\"; ```","Lint\n\nHow to validate docs content with lintDocs and the @inth/docs lint CLI.\n\nLint\n\nWhat It Checks\n\nDocs page frontmatter schema validation Changelog frontmatter schema validation when configured meta.json structure Broken /docs/... links in rendered markdown Broken /docs/... links in URL-like frontmatter fields Unresolved framework placeholders in docs URLs Cross-framework links such as a Next.js page linking to a React-only docs route Linting reads source .md and .mdx files, then renders MDX through the default remark pipeline for link checks. Point it at source docs, not generated markdown.","Lint\n\nHow to validate docs content with lintDocs and the @inth/docs lint CLI.\n\nLint\n\nCLI Reference\n\nOption Description -- -- srcDir Source directory to scan. Defaults to content when no positional path or --src is provided. --src ; summary: { filesScanned: number; errors: number; warnings: number; }; }; ```","Lint\n\nHow to validate docs content with lintDocs and the @inth/docs lint CLI.\n\nLint\n\nRule Reference\n\nRule Severity Meaning -- -- -- schema error A value failed the active frontmatter, changelog, or meta.json schema. unknown-field warn by default A top-level field is not in the active schema and is not read by the default consumers. parse-error error Frontmatter, meta.json , or rendered markdown could not be parsed for validation. invalid-link error A /docs/... link points to a route that does not exist in the source docs tree. unresolved-placeholder error A docs URL still contains an unresolved placeholder such as framework . cross-framework-link error A framework-scoped page links to a different framework's docs route.","Lint\n\nHow to validate docs content with lintDocs and the @inth/docs lint CLI.\n\nLint\n\nDefault Schemas\n\nDocs Frontmatter\n\nField Required Type -- -- -- title Yes non-empty string description No string icon No string deprecated No boolean deprecatedReason No string experimental No boolean canary No boolean new No boolean draft No boolean tags No string array group No string or string array availableIn No array of framework, url?, title? full No boolean lastModified and lastAuthor are generated by conversion when enrichFrontmatterFromGit is enabled. Do not author them in source docs.","Lint\n\nHow to validate docs content with lintDocs and the @inth/docs lint CLI.\n\nLint\n\nDefault Schemas\n\nChangelog Frontmatter\n\nField Required Type -- -- -- title Yes non-empty string version Yes SemVer string date Yes ISO-8601 or parseable date string description No string icon No string type No release , improvement , retired , or deprecation tags No string array canary No boolean authors No string or string array draft No boolean","Lint\n\nHow to validate docs content with lintDocs and the @inth/docs lint CLI.\n\nLint\n\nDefault Schemas\n\nmeta.json\n\nField Required Type -- -- -- pages Yes string array title No non-empty string root No boolean icon No string defaultOpen No boolean nav.sidebar No section or combined nav.label No string nav.mode No string","Lint\n\nHow to validate docs content with lintDocs and the @inth/docs lint CLI.\n\nLint\n\nGuidance\n\nRun lint before @inth/docs generate so content errors fail before artifacts are written. Use --format json for automation and --format github for GitHub Actions annotations. Treat unresolved placeholder errors as content bugs first. If lint fails after a docs move, check meta.json and internal links together; they usually drift at the same time.","LLM\n\nHow to generate llms.txt and topic-scoped full-context files from @inth/docs.\n\nLLM\n\nImport from This surface reads source docs and generated markdown to produce agent-friendly indexes and deep-context bundles.\n\n```ts import { generateLLMFullContextFiles, generateLlmsTxt, } from \"@inth/docs/llm\"; ```","LLM\n\nHow to generate llms.txt and topic-scoped full-context files from @inth/docs.\n\nLLM\n\nOutput Model\n\ngenerateLlmsTxt\n\nCreates /llms.txt /docs/llms.txt when docsSections is provided Use it to publish a short product summary plus a curated docs map.","LLM\n\nHow to generate llms.txt and topic-scoped full-context files from @inth/docs.\n\nLLM\n\nOutput Model\n\ngenerateLLMFullContextFiles\n\nCreates /llms-full.txt /docs/llms-full.txt /docs/llms-full/ .txt topic files Use it after markdown conversion. It reads .md files under outDir /docs/ .","LLM\n\nHow to generate llms.txt and topic-scoped full-context files from @inth/docs.\n\nLLM\n\nRequired Conventions\n\nSource docs for summaries live under srcDir /docs/ . Converted markdown for full files lives under outDir /docs/ . Run convertAllMdx before generateLLMFullContextFiles .","LLM\n\nHow to generate llms.txt and topic-scoped full-context files from @inth/docs.\n\nLLM\n\nTypical Sequence\n\n```ts await convertAllMdx({ srcDir, outDir, remarkPlugins: [remarkInclude, ...defaultRemarkPlugins], }); await generateLlmsTxt({ srcDir, outDir, baseUrl, product: { name: \"My Docs\", summary: \"Short product summary.\", }, docsSections: [ { title: \"Guides\", links: [{ urlPath: \"/docs/guides/quickstart\" }], }, ], }); await generateLLMFullContextFiles({ outDir, baseUrl, product: { name: \"My Docs\" }, topics: [ { slug: \"guides\", title: \"Guides\", description: \"Full context for guides.\", includePrefixes: [\"guides/\"], }, ], }); ```","LLM\n\nHow to generate llms.txt and topic-scoped full-context files from @inth/docs.\n\nLLM\n\nTopic Design\n\nPrefer multiple narrow topics over one giant full-context file. Good frameworks , self-host , integrations Poor one catch-all topic for the whole docs tree The APIs support nested routers, so parent topics can point to smaller child topics.","LLM\n\nHow to generate llms.txt and topic-scoped full-context files from @inth/docs.\n\nLLM\n\nGuidance\n\nKeep curated summary links opinionated. They should help an agent choose the smallest useful file. Write short, explicit descriptions for topics and sections. Those descriptions become routing hints. If generated files are empty, check that the docs really live under the expected docs/ folder names.","Methodology\n\nHow @inth/docs differs from Fumadocs, Mintlify, and Starlight\n\nMethodology\n\n@inth/docs is not a docs website framework. It is the shared pipeline behind docs websites. We built it at Inth because projects like c15t and dsar-sdk need one consistent docs system across packages, repos, websites, generated markdown, llms.txt , search, and package-bundled agent docs. The browser UI stays owned by your app. @inth/docs makes the content portable.","Methodology\n\nHow @inth/docs differs from Fumadocs, Mintlify, and Starlight\n\nMethodology\n\nThe short version\n\nFumadocs helps you build a React docs site. Starlight helps you build an Astro docs site. Mintlify gives you a hosted docs platform. @inth/docs helps you reuse the same docs content across sites, packages, search, and agents. Use the others when the main job is publishing a polished website quickly. Use @inth/docs when the main job is standardizing the docs infrastructure behind one or more websites.","Methodology\n\nHow @inth/docs differs from Fumadocs, Mintlify, and Starlight\n\nMethodology\n\nWhat it does\n\n@inth/docs gives you framework-neutral tools to Convert MDX-heavy docs into clean markdown. Generate llms.txt and full-context topic files. Build static search artifacts. Validate frontmatter, navigation metadata, and links. Ship agent-readable docs inside published packages. It does not provide the visual docs UI, routing, theme, or hosted deployment.","Methodology\n\nHow @inth/docs differs from Fumadocs, Mintlify, and Starlight\n\nMethodology\n\nUnified docs across repos\n\nThis is useful when an organization has many packages but wants one docs experience. Each repo can keep its MDX next to the code it documents. A shared or private docs UI can render that content with the organization's design system, while @inth/docs handles conversion, linting, search data, and agent-doc generation. That gives every repo the same output contract without forcing every repo to own the same website implementation.","Methodology\n\nHow @inth/docs differs from Fumadocs, Mintlify, and Starlight\n\nMethodology\n\nWhen to use what\n\nUse Fumadocs for a composable React docs framework. Use Starlight for an Astro-first docs site with strong defaults. Use Mintlify for hosted docs with deployment, analytics, API docs, and AI features managed for you. Use @inth/docs when you own the docs app but need shared infrastructure across repos, packages, generated markdown, search, and agent-facing docs. These tools can also be used together use a docs framework or hosted platform for the browser experience, and use @inth/docs for portable docs outputs behind it.","Remark\n\nReference for the remark plugins and default plugin pipeline exported by @inth/docs.\n\nRemark\n\nImport from\n\n```ts import { defaultRemarkPlugins, remarkInclude, remarkTypeTableToMarkdown, } from \"@inth/docs/remark\"; ```","Remark\n\nReference for the remark plugins and default plugin pipeline exported by @inth/docs.\n\nRemark\n\nDefault Plugin Stack\n\ndefaultRemarkPlugins is the standard MDX-to-markdown pipeline for agent docs. Order matters. The stack 1. Removes MDX imports. 2. Resolves docs placeholders. 3. Flattens JSX-heavy authoring components into plain markdown. The default array includes remarkRemoveImports remarkResolveDocPlaceholders remarkCalloutToMarkdown remarkCardsToMarkdown remarkMermaidToMarkdown remarkCommandTabsToMarkdown remarkStepsToMarkdown remarkTabsToMarkdown remarkTypeTableToMarkdown","Remark\n\nReference for the remark plugins and default plugin pipeline exported by @inth/docs.\n\nRemark\n\nWhen To Add Extra Plugins\n\nremarkInclude\n\nAdd this when the source docs use include tags or partial composition Place it before the default plugins so included content is expanded before JSX flattening runs.\n\n```ts remarkPlugins: [remarkInclude, ...defaultRemarkPlugins] ```","Remark\n\nReference for the remark plugins and default plugin pipeline exported by @inth/docs.\n\nRemark\n\nWhen To Add Extra Plugins\n\nremarkTypeTableToMarkdown\n\nThis plugin can be used directly when you only need type-table extraction and not the full pipeline. It pairs with ({ + label: page.title, + to: page.urlPath, + })); + const nested = group.children.flatMap(flattenGroupPages); + return [...direct, ...nested]; +} + +/** + * Sidebar nav for the /docs surface, derived from the build-time `docs-nav.json` + * manifest. The manifest is generated by `apps/example/scripts/llm-generate.ts` + * (which calls `resolveDocsNavigation` from `@inth/docs/llm`) and reflects the + * `group:` frontmatter on each `/docs/*.mdx`. This is exactly how an external + * consumer like c15t/c15t would wire its sidebar. + */ +export const docsSidebarSections: DocsSidebarSection[] = navigation.groups.map( + (group) => ({ + title: group.title, + description: group.description, + links: flattenGroupPages(group), + }) +); + export interface PackageSurface { description: string; importPath: string; @@ -56,6 +75,11 @@ export interface PackageSurface { } export const packageSurfaces: PackageSurface[] = [ + { + importPath: "@inth/docs", + lifecycle: "build time", + description: "Root export with `defineDocsConfig` and shared types.", + }, { importPath: "@inth/docs/remark", lifecycle: "build time", diff --git a/apps/docs-smoke/src/lib/search.ts b/apps/example/src/lib/search.ts similarity index 100% rename from apps/docs-smoke/src/lib/search.ts rename to apps/example/src/lib/search.ts diff --git a/apps/example/src/lib/use-docs-search.ts b/apps/example/src/lib/use-docs-search.ts new file mode 100644 index 0000000..4b9e195 --- /dev/null +++ b/apps/example/src/lib/use-docs-search.ts @@ -0,0 +1,412 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { DemoSearchApiResult } from "@/lib/search"; + +export interface AnswerConfig { + enabled: boolean; + model: string; +} + +export type SearchStatus = "idle" | "loading" | "error"; +export type AnswerStatus = + | "idle" + | "loading" + | "streaming" + | "error" + | "disabled"; + +const SEARCH_DEBOUNCE_MS = 100; +export const SEARCH_MAX_QUERY_LENGTH = 400; + +class AnswerRequestError extends Error {} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +function getAnswerFailureMessage(error: unknown): string { + return error instanceof AnswerRequestError + ? error.message + : "Answer generation failed."; +} + +async function readAnswerStream( + response: Response, + options: { + isCurrent: () => boolean; + onText: (text: string) => void; + signal: AbortSignal; + } +): Promise { + if (!response.body) { + return ""; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let streamedAnswer = ""; + while (true) { + const chunk = await reader.read(); + if (options.signal.aborted || !options.isCurrent()) { + await reader.cancel(); + return; + } + if (chunk.done) { + break; + } + const text = decoder.decode(chunk.value, { stream: true }); + streamedAnswer += text; + options.onText(text); + } + + const remainingText = decoder.decode(); + if (remainingText) { + streamedAnswer += remainingText; + options.onText(remainingText); + } + return streamedAnswer; +} + +async function readAnswerErrorMessage(response: Response): Promise { + const data = (await response.json().catch(() => null)) as { + error?: string; + } | null; + return data?.error ?? "Answer generation failed."; +} + +async function streamDocsAnswer(options: { + isCurrent: () => boolean; + onText: (text: string) => void; + query: string; + signal: AbortSignal; +}): Promise { + const response = await fetch("/api/docs/ask", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: options.query }), + signal: options.signal, + }); + if (!options.isCurrent()) { + return; + } + if (!(response.ok && response.body)) { + throw new AnswerRequestError(await readAnswerErrorMessage(response)); + } + + return readAnswerStream(response, { + isCurrent: options.isCurrent, + onText: options.onText, + signal: options.signal, + }); +} + +export interface UseDocsSearchResult { + answer: string; + answerConfig: AnswerConfig; + answerStatus: AnswerStatus; + /** Stream an AI-generated answer for the current query, or for `overrideQuery` if provided. */ + askAi: (overrideQuery?: string) => Promise; + /** Cancel any in-flight search and answer requests. */ + cancel: () => void; + error: string; + query: string; + /** Reset to a clean state (clears query, results, answer, error). */ + reset: () => void; + results: DemoSearchApiResult["results"]; + /** Run an immediate (non-debounced) search; useful for form submit. */ + runSearch: (signal?: AbortSignal) => Promise; + searchStatus: SearchStatus; + setQuery: (next: string) => void; +} + +/** + * Manages the shared search-and-stream state for both the header search popover + * and the standalone /search page. Encapsulates debouncing, abort handling, + * answer streaming, and the AI-answer config probe so the two surfaces stay + * behaviorally identical. + */ +export function useDocsSearch(initialQuery = ""): UseDocsSearchResult { + const searchTimeoutRef = useRef(undefined); + const searchControllerRef = useRef(null); + const askControllerRef = useRef(null); + const askRequestIdRef = useRef(0); + const [query, setQueryState] = useState(initialQuery); + const [searchStatus, setSearchStatus] = useState("idle"); + const [answerStatus, setAnswerStatus] = useState("idle"); + const [results, setResults] = useState([]); + const [answer, setAnswer] = useState(""); + const [error, setError] = useState(""); + const [answerConfig, setAnswerConfig] = useState({ + enabled: false, + model: "moonshotai/kimi-k2.6", + }); + + useEffect(() => { + let active = true; + async function loadAnswerConfig() { + const response = await fetch("/api/docs/ask"); + if (!response.ok) { + return; + } + const data = (await response.json()) as AnswerConfig; + if (active) { + setAnswerConfig(data); + setAnswerStatus(data.enabled ? "idle" : "disabled"); + } + } + const configPromise = loadAnswerConfig(); + configPromise.catch(() => undefined); + return () => { + active = false; + }; + }, []); + + const performSearch = useCallback( + async ( + nextQuery: string, + signal?: AbortSignal + ): Promise => { + const trimmedQuery = nextQuery.trim(); + if (!trimmedQuery) { + return []; + } + + try { + setSearchStatus("loading"); + setError(""); + const response = await fetch( + `/api/docs/search?q=${encodeURIComponent(trimmedQuery)}`, + { signal } + ); + const data = (await response.json()) as + | DemoSearchApiResult + | { error: string }; + + if (!response.ok || "error" in data) { + setSearchStatus("error"); + const message = "error" in data ? data.error : "Search failed."; + setError(message); + return []; + } + + setResults(data.results); + setSearchStatus("idle"); + return data.results; + } catch (caughtError) { + if (isAbortError(caughtError)) { + return []; + } + setSearchStatus("error"); + setError("Search failed."); + return []; + } + }, + [] + ); + + const cancelPendingSearch = useCallback(() => { + if (searchTimeoutRef.current !== undefined) { + window.clearTimeout(searchTimeoutRef.current); + searchTimeoutRef.current = undefined; + } + searchControllerRef.current?.abort(); + searchControllerRef.current = null; + }, []); + + const cancelPendingAnswer = useCallback(() => { + askRequestIdRef.current += 1; + askControllerRef.current?.abort(); + askControllerRef.current = null; + }, []); + + const cancel = useCallback(() => { + cancelPendingSearch(); + cancelPendingAnswer(); + }, [cancelPendingAnswer, cancelPendingSearch]); + + const setQuery = useCallback( + (next: string) => { + cancelPendingAnswer(); + setQueryState(next); + }, + [cancelPendingAnswer] + ); + + // Debounced reactive search whenever the query changes. + useEffect(() => { + const trimmedQuery = query.trim(); + if (!trimmedQuery) { + cancelPendingSearch(); + setResults([]); + setSearchStatus("idle"); + setError(""); + return; + } + + cancelPendingSearch(); + const controller = new AbortController(); + searchControllerRef.current = controller; + searchTimeoutRef.current = window.setTimeout(() => { + searchTimeoutRef.current = undefined; + const searchPromise = performSearch(trimmedQuery, controller.signal); + searchPromise.finally(() => { + if (searchControllerRef.current === controller) { + searchControllerRef.current = null; + } + }); + }, SEARCH_DEBOUNCE_MS); + + return () => { + if (searchTimeoutRef.current !== undefined) { + window.clearTimeout(searchTimeoutRef.current); + searchTimeoutRef.current = undefined; + } + if (searchControllerRef.current === controller) { + controller.abort(); + searchControllerRef.current = null; + } + }; + }, [cancelPendingSearch, performSearch, query]); + + // Cleanup on unmount. + useEffect( + () => () => { + cancelPendingAnswer(); + }, + [cancelPendingAnswer] + ); + + const runSearch = useCallback( + async (signal?: AbortSignal) => { + cancelPendingSearch(); + cancelPendingAnswer(); + setAnswer(""); + const controller = signal ? null : new AbortController(); + const effectiveSignal = signal ?? controller?.signal; + if (controller) { + searchControllerRef.current = controller; + } + try { + return await performSearch(query, effectiveSignal); + } finally { + if (controller && searchControllerRef.current === controller) { + searchControllerRef.current = null; + } + } + }, + [cancelPendingAnswer, cancelPendingSearch, performSearch, query] + ); + + const performAnswer = useCallback( + async (trimmedQuery: string) => { + if (!(trimmedQuery && answerConfig.enabled)) { + return; + } + + cancelPendingSearch(); + cancelPendingAnswer(); + const requestId = askRequestIdRef.current + 1; + askRequestIdRef.current = requestId; + const controller = new AbortController(); + askControllerRef.current = controller; + + const isCurrent = () => + !controller.signal.aborted && askRequestIdRef.current === requestId; + + const setAnswerError = (message: string) => { + setAnswerStatus("error"); + setError(message); + }; + + try { + setAnswer(""); + setError(""); + setAnswerStatus("loading"); + + const sourceResults = await performSearch( + trimmedQuery, + controller.signal + ); + if (!isCurrent()) { + return; + } + if (sourceResults.length === 0) { + setAnswerError("No matching docs were found for that question."); + return; + } + + setAnswerStatus("streaming"); + const streamedAnswer = await streamDocsAnswer({ + isCurrent, + onText: (text) => setAnswer((current) => current + text), + query: trimmedQuery, + signal: controller.signal, + }); + if (!isCurrent()) { + return; + } + if (!streamedAnswer?.trim()) { + setAnswerError( + "The AI provider returned an empty answer. Check AI Gateway auth and model access." + ); + return; + } + setAnswerStatus("idle"); + } catch (caughtError) { + if (isAbortError(caughtError)) { + return; + } + setAnswerError(getAnswerFailureMessage(caughtError)); + } finally { + if (askControllerRef.current === controller) { + askControllerRef.current = null; + } + } + }, + [ + answerConfig.enabled, + cancelPendingAnswer, + cancelPendingSearch, + performSearch, + ] + ); + + const askAi = useCallback( + async (overrideQuery?: string) => { + const trimmedQuery = (overrideQuery ?? query).trim(); + if (overrideQuery !== undefined) { + setQueryState(trimmedQuery); + } + await performAnswer(trimmedQuery); + }, + [performAnswer, query] + ); + + const reset = useCallback(() => { + cancel(); + setQueryState(""); + setResults([]); + setAnswer(""); + setError(""); + setSearchStatus("idle"); + if (answerConfig.enabled) { + setAnswerStatus("idle"); + } + }, [answerConfig.enabled, cancel]); + + return { + query, + setQuery, + results, + searchStatus, + answer, + answerStatus, + answerConfig, + error, + runSearch, + askAi, + cancel, + reset, + }; +} diff --git a/apps/docs-smoke/src/lib/utils.ts b/apps/example/src/lib/utils.ts similarity index 100% rename from apps/docs-smoke/src/lib/utils.ts rename to apps/example/src/lib/utils.ts diff --git a/apps/docs-smoke/src/mdx-components.tsx b/apps/example/src/mdx-components.tsx similarity index 100% rename from apps/docs-smoke/src/mdx-components.tsx rename to apps/example/src/mdx-components.tsx diff --git a/apps/docs-smoke/src/mdx.d.ts b/apps/example/src/mdx.d.ts similarity index 100% rename from apps/docs-smoke/src/mdx.d.ts rename to apps/example/src/mdx.d.ts diff --git a/apps/docs-smoke/src/routeTree.gen.ts b/apps/example/src/routeTree.gen.ts similarity index 52% rename from apps/docs-smoke/src/routeTree.gen.ts rename to apps/example/src/routeTree.gen.ts index 6480b9b..b3b99c8 100644 --- a/apps/docs-smoke/src/routeTree.gen.ts +++ b/apps/example/src/routeTree.gen.ts @@ -15,8 +15,14 @@ import { Route as DocsRouteRouteImport } from './routes/docs/route' import { Route as IndexRouteImport } from './routes/index' import { Route as DocsIndexRouteImport } from './routes/docs/index' import { Route as DocsSearchRouteImport } from './routes/docs/search' -import { Route as DocsGuidesQuickstartRouteImport } from './routes/docs/guides/quickstart' -import { Route as DocsGuidesComponentsFixtureRouteImport } from './routes/docs/guides/components-fixture' +import { Route as DocsRemarkRouteImport } from './routes/docs/remark' +import { Route as DocsMethodologyRouteImport } from './routes/docs/methodology' +import { Route as DocsLlmRouteImport } from './routes/docs/llm' +import { Route as DocsLintRouteImport } from './routes/docs/lint' +import { Route as DocsConvertRouteImport } from './routes/docs/convert' +import { Route as DocsComponentsRouteImport } from './routes/docs/components' +import { Route as DocsGuidesConnectDocsSiteRouteImport } from './routes/docs/guides/connect-docs-site' +import { Route as DocsGuidesBundlePackageDocsRouteImport } from './routes/docs/guides/bundle-package-docs' import { Route as ApiDocsSearchRouteImport } from './routes/api/docs/search' import { Route as ApiDocsAskRouteImport } from './routes/api/docs/ask' @@ -50,15 +56,46 @@ const DocsSearchRoute = DocsSearchRouteImport.update({ path: '/search', getParentRoute: () => DocsRouteRoute, } as any) -const DocsGuidesQuickstartRoute = DocsGuidesQuickstartRouteImport.update({ - id: '/guides/quickstart', - path: '/guides/quickstart', +const DocsRemarkRoute = DocsRemarkRouteImport.update({ + id: '/remark', + path: '/remark', getParentRoute: () => DocsRouteRoute, } as any) -const DocsGuidesComponentsFixtureRoute = - DocsGuidesComponentsFixtureRouteImport.update({ - id: '/guides/components-fixture', - path: '/guides/components-fixture', +const DocsMethodologyRoute = DocsMethodologyRouteImport.update({ + id: '/methodology', + path: '/methodology', + getParentRoute: () => DocsRouteRoute, +} as any) +const DocsLlmRoute = DocsLlmRouteImport.update({ + id: '/llm', + path: '/llm', + getParentRoute: () => DocsRouteRoute, +} as any) +const DocsLintRoute = DocsLintRouteImport.update({ + id: '/lint', + path: '/lint', + getParentRoute: () => DocsRouteRoute, +} as any) +const DocsConvertRoute = DocsConvertRouteImport.update({ + id: '/convert', + path: '/convert', + getParentRoute: () => DocsRouteRoute, +} as any) +const DocsComponentsRoute = DocsComponentsRouteImport.update({ + id: '/components', + path: '/components', + getParentRoute: () => DocsRouteRoute, +} as any) +const DocsGuidesConnectDocsSiteRoute = + DocsGuidesConnectDocsSiteRouteImport.update({ + id: '/guides/connect-docs-site', + path: '/guides/connect-docs-site', + getParentRoute: () => DocsRouteRoute, + } as any) +const DocsGuidesBundlePackageDocsRoute = + DocsGuidesBundlePackageDocsRouteImport.update({ + id: '/guides/bundle-package-docs', + path: '/guides/bundle-package-docs', getParentRoute: () => DocsRouteRoute, } as any) const ApiDocsSearchRoute = ApiDocsSearchRouteImport.update({ @@ -77,23 +114,35 @@ export interface FileRoutesByFullPath { '/docs': typeof DocsRouteRouteWithChildren '/playground': typeof PlaygroundRoute '/search': typeof SearchRoute + '/docs/components': typeof DocsComponentsRoute + '/docs/convert': typeof DocsConvertRoute + '/docs/lint': typeof DocsLintRoute + '/docs/llm': typeof DocsLlmRoute + '/docs/methodology': typeof DocsMethodologyRoute + '/docs/remark': typeof DocsRemarkRoute '/docs/search': typeof DocsSearchRoute '/docs/': typeof DocsIndexRoute '/api/docs/ask': typeof ApiDocsAskRoute '/api/docs/search': typeof ApiDocsSearchRoute - '/docs/guides/components-fixture': typeof DocsGuidesComponentsFixtureRoute - '/docs/guides/quickstart': typeof DocsGuidesQuickstartRoute + '/docs/guides/bundle-package-docs': typeof DocsGuidesBundlePackageDocsRoute + '/docs/guides/connect-docs-site': typeof DocsGuidesConnectDocsSiteRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/playground': typeof PlaygroundRoute '/search': typeof SearchRoute + '/docs/components': typeof DocsComponentsRoute + '/docs/convert': typeof DocsConvertRoute + '/docs/lint': typeof DocsLintRoute + '/docs/llm': typeof DocsLlmRoute + '/docs/methodology': typeof DocsMethodologyRoute + '/docs/remark': typeof DocsRemarkRoute '/docs/search': typeof DocsSearchRoute '/docs': typeof DocsIndexRoute '/api/docs/ask': typeof ApiDocsAskRoute '/api/docs/search': typeof ApiDocsSearchRoute - '/docs/guides/components-fixture': typeof DocsGuidesComponentsFixtureRoute - '/docs/guides/quickstart': typeof DocsGuidesQuickstartRoute + '/docs/guides/bundle-package-docs': typeof DocsGuidesBundlePackageDocsRoute + '/docs/guides/connect-docs-site': typeof DocsGuidesConnectDocsSiteRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -101,12 +150,18 @@ export interface FileRoutesById { '/docs': typeof DocsRouteRouteWithChildren '/playground': typeof PlaygroundRoute '/search': typeof SearchRoute + '/docs/components': typeof DocsComponentsRoute + '/docs/convert': typeof DocsConvertRoute + '/docs/lint': typeof DocsLintRoute + '/docs/llm': typeof DocsLlmRoute + '/docs/methodology': typeof DocsMethodologyRoute + '/docs/remark': typeof DocsRemarkRoute '/docs/search': typeof DocsSearchRoute '/docs/': typeof DocsIndexRoute '/api/docs/ask': typeof ApiDocsAskRoute '/api/docs/search': typeof ApiDocsSearchRoute - '/docs/guides/components-fixture': typeof DocsGuidesComponentsFixtureRoute - '/docs/guides/quickstart': typeof DocsGuidesQuickstartRoute + '/docs/guides/bundle-package-docs': typeof DocsGuidesBundlePackageDocsRoute + '/docs/guides/connect-docs-site': typeof DocsGuidesConnectDocsSiteRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -115,35 +170,53 @@ export interface FileRouteTypes { | '/docs' | '/playground' | '/search' + | '/docs/components' + | '/docs/convert' + | '/docs/lint' + | '/docs/llm' + | '/docs/methodology' + | '/docs/remark' | '/docs/search' | '/docs/' | '/api/docs/ask' | '/api/docs/search' - | '/docs/guides/components-fixture' - | '/docs/guides/quickstart' + | '/docs/guides/bundle-package-docs' + | '/docs/guides/connect-docs-site' fileRoutesByTo: FileRoutesByTo to: | '/' | '/playground' | '/search' + | '/docs/components' + | '/docs/convert' + | '/docs/lint' + | '/docs/llm' + | '/docs/methodology' + | '/docs/remark' | '/docs/search' | '/docs' | '/api/docs/ask' | '/api/docs/search' - | '/docs/guides/components-fixture' - | '/docs/guides/quickstart' + | '/docs/guides/bundle-package-docs' + | '/docs/guides/connect-docs-site' id: | '__root__' | '/' | '/docs' | '/playground' | '/search' + | '/docs/components' + | '/docs/convert' + | '/docs/lint' + | '/docs/llm' + | '/docs/methodology' + | '/docs/remark' | '/docs/search' | '/docs/' | '/api/docs/ask' | '/api/docs/search' - | '/docs/guides/components-fixture' - | '/docs/guides/quickstart' + | '/docs/guides/bundle-package-docs' + | '/docs/guides/connect-docs-site' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -199,18 +272,60 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DocsSearchRouteImport parentRoute: typeof DocsRouteRoute } - '/docs/guides/quickstart': { - id: '/docs/guides/quickstart' - path: '/guides/quickstart' - fullPath: '/docs/guides/quickstart' - preLoaderRoute: typeof DocsGuidesQuickstartRouteImport + '/docs/remark': { + id: '/docs/remark' + path: '/remark' + fullPath: '/docs/remark' + preLoaderRoute: typeof DocsRemarkRouteImport + parentRoute: typeof DocsRouteRoute + } + '/docs/methodology': { + id: '/docs/methodology' + path: '/methodology' + fullPath: '/docs/methodology' + preLoaderRoute: typeof DocsMethodologyRouteImport + parentRoute: typeof DocsRouteRoute + } + '/docs/llm': { + id: '/docs/llm' + path: '/llm' + fullPath: '/docs/llm' + preLoaderRoute: typeof DocsLlmRouteImport + parentRoute: typeof DocsRouteRoute + } + '/docs/lint': { + id: '/docs/lint' + path: '/lint' + fullPath: '/docs/lint' + preLoaderRoute: typeof DocsLintRouteImport + parentRoute: typeof DocsRouteRoute + } + '/docs/convert': { + id: '/docs/convert' + path: '/convert' + fullPath: '/docs/convert' + preLoaderRoute: typeof DocsConvertRouteImport + parentRoute: typeof DocsRouteRoute + } + '/docs/components': { + id: '/docs/components' + path: '/components' + fullPath: '/docs/components' + preLoaderRoute: typeof DocsComponentsRouteImport + parentRoute: typeof DocsRouteRoute + } + '/docs/guides/connect-docs-site': { + id: '/docs/guides/connect-docs-site' + path: '/guides/connect-docs-site' + fullPath: '/docs/guides/connect-docs-site' + preLoaderRoute: typeof DocsGuidesConnectDocsSiteRouteImport parentRoute: typeof DocsRouteRoute } - '/docs/guides/components-fixture': { - id: '/docs/guides/components-fixture' - path: '/guides/components-fixture' - fullPath: '/docs/guides/components-fixture' - preLoaderRoute: typeof DocsGuidesComponentsFixtureRouteImport + '/docs/guides/bundle-package-docs': { + id: '/docs/guides/bundle-package-docs' + path: '/guides/bundle-package-docs' + fullPath: '/docs/guides/bundle-package-docs' + preLoaderRoute: typeof DocsGuidesBundlePackageDocsRouteImport parentRoute: typeof DocsRouteRoute } '/api/docs/search': { @@ -231,17 +346,29 @@ declare module '@tanstack/react-router' { } interface DocsRouteRouteChildren { + DocsComponentsRoute: typeof DocsComponentsRoute + DocsConvertRoute: typeof DocsConvertRoute + DocsLintRoute: typeof DocsLintRoute + DocsLlmRoute: typeof DocsLlmRoute + DocsMethodologyRoute: typeof DocsMethodologyRoute + DocsRemarkRoute: typeof DocsRemarkRoute DocsSearchRoute: typeof DocsSearchRoute DocsIndexRoute: typeof DocsIndexRoute - DocsGuidesComponentsFixtureRoute: typeof DocsGuidesComponentsFixtureRoute - DocsGuidesQuickstartRoute: typeof DocsGuidesQuickstartRoute + DocsGuidesBundlePackageDocsRoute: typeof DocsGuidesBundlePackageDocsRoute + DocsGuidesConnectDocsSiteRoute: typeof DocsGuidesConnectDocsSiteRoute } const DocsRouteRouteChildren: DocsRouteRouteChildren = { + DocsComponentsRoute: DocsComponentsRoute, + DocsConvertRoute: DocsConvertRoute, + DocsLintRoute: DocsLintRoute, + DocsLlmRoute: DocsLlmRoute, + DocsMethodologyRoute: DocsMethodologyRoute, + DocsRemarkRoute: DocsRemarkRoute, DocsSearchRoute: DocsSearchRoute, DocsIndexRoute: DocsIndexRoute, - DocsGuidesComponentsFixtureRoute: DocsGuidesComponentsFixtureRoute, - DocsGuidesQuickstartRoute: DocsGuidesQuickstartRoute, + DocsGuidesBundlePackageDocsRoute: DocsGuidesBundlePackageDocsRoute, + DocsGuidesConnectDocsSiteRoute: DocsGuidesConnectDocsSiteRoute, } const DocsRouteRouteWithChildren = DocsRouteRoute._addFileChildren( diff --git a/apps/docs-smoke/src/router.tsx b/apps/example/src/router.tsx similarity index 100% rename from apps/docs-smoke/src/router.tsx rename to apps/example/src/router.tsx diff --git a/apps/example/src/routes/__root.tsx b/apps/example/src/routes/__root.tsx new file mode 100644 index 0000000..5ab0564 --- /dev/null +++ b/apps/example/src/routes/__root.tsx @@ -0,0 +1,78 @@ +import { MDXProvider } from "@mdx-js/react"; +import { + createRootRoute, + HeadContent, + Scripts, + useRouterState, +} from "@tanstack/react-router"; +import { type ReactNode, useEffect } from "react"; +import { useMDXComponents } from "@/mdx-components"; +import appCss from "../styles.css?url"; + +const HASH_PREFIX_PATTERN = /^#/; + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: "utf-8" }, + { + content: "width=device-width, initial-scale=1", + name: "viewport", + }, + { + title: "@inth/docs reference app", + }, + { + content: + "Reference routes for MDX, components, and playground coverage.", + name: "description", + }, + ], + links: [{ rel: "stylesheet", href: appCss }], + }), + shellComponent: RootDocument, +}); + +/** + * Scroll to the element that matches the current URL hash whenever the hash + * changes. TanStack Router's `scrollRestoration` handles raw scroll position + * but doesn't handle hash anchors — so direct URL loads and programmatic + * `navigate({ hash })` both land at the top of the page without this. + */ +function ScrollToHash() { + const hash = useRouterState({ select: (state) => state.location.hash }); + useEffect(() => { + if (!hash) { + return; + } + const id = hash.replace(HASH_PREFIX_PATTERN, ""); + if (!id) { + return; + } + // Wait two frames so the destination route has rendered before we look up + // the element by id. Single RAF is sometimes too early on initial mount. + const outer = requestAnimationFrame(() => { + const inner = requestAnimationFrame(() => { + document.getElementById(id)?.scrollIntoView({ block: "start" }); + }); + return inner; + }); + return () => cancelAnimationFrame(outer); + }, [hash]); + return null; +} + +function RootDocument({ children }: { children: ReactNode }) { + return ( + + + + + + {children} + + + + + ); +} diff --git a/apps/docs-smoke/src/routes/api/docs/ask.ts b/apps/example/src/routes/api/docs/ask.ts similarity index 100% rename from apps/docs-smoke/src/routes/api/docs/ask.ts rename to apps/example/src/routes/api/docs/ask.ts diff --git a/apps/docs-smoke/src/routes/api/docs/search.ts b/apps/example/src/routes/api/docs/search.ts similarity index 100% rename from apps/docs-smoke/src/routes/api/docs/search.ts rename to apps/example/src/routes/api/docs/search.ts diff --git a/apps/example/src/routes/docs/components.tsx b/apps/example/src/routes/docs/components.tsx new file mode 100644 index 0000000..86be51a --- /dev/null +++ b/apps/example/src/routes/docs/components.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import ComponentsDoc from "../../../../../docs/components.mdx"; + +export const Route = createFileRoute("/docs/components")({ + component: ComponentsRoute, +}); + +function ComponentsRoute() { + return ; +} diff --git a/apps/example/src/routes/docs/convert.tsx b/apps/example/src/routes/docs/convert.tsx new file mode 100644 index 0000000..11a8516 --- /dev/null +++ b/apps/example/src/routes/docs/convert.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import ConvertDoc from "../../../../../docs/convert.mdx"; + +export const Route = createFileRoute("/docs/convert")({ + component: ConvertRoute, +}); + +function ConvertRoute() { + return ; +} diff --git a/apps/example/src/routes/docs/guides/bundle-package-docs.tsx b/apps/example/src/routes/docs/guides/bundle-package-docs.tsx new file mode 100644 index 0000000..0784d81 --- /dev/null +++ b/apps/example/src/routes/docs/guides/bundle-package-docs.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import BundlePackageDocsDoc from "../../../../../../docs/guides/bundle-package-docs.mdx"; + +export const Route = createFileRoute("/docs/guides/bundle-package-docs")({ + component: BundlePackageDocsRoute, +}); + +function BundlePackageDocsRoute() { + return ; +} diff --git a/apps/example/src/routes/docs/guides/connect-docs-site.tsx b/apps/example/src/routes/docs/guides/connect-docs-site.tsx new file mode 100644 index 0000000..dbd9bcf --- /dev/null +++ b/apps/example/src/routes/docs/guides/connect-docs-site.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import ConnectDocsSiteDoc from "../../../../../../docs/guides/connect-docs-site.mdx"; + +export const Route = createFileRoute("/docs/guides/connect-docs-site")({ + component: ConnectDocsSiteRoute, +}); + +function ConnectDocsSiteRoute() { + return ; +} diff --git a/apps/example/src/routes/docs/index.tsx b/apps/example/src/routes/docs/index.tsx new file mode 100644 index 0000000..128c106 --- /dev/null +++ b/apps/example/src/routes/docs/index.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import DocsIndex from "../../../../../docs/index.mdx"; + +export const Route = createFileRoute("/docs/")({ + component: DocsIndexRoute, +}); + +function DocsIndexRoute() { + return ; +} diff --git a/apps/example/src/routes/docs/lint.tsx b/apps/example/src/routes/docs/lint.tsx new file mode 100644 index 0000000..56fd03e --- /dev/null +++ b/apps/example/src/routes/docs/lint.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import LintDoc from "../../../../../docs/lint.mdx"; + +export const Route = createFileRoute("/docs/lint")({ + component: LintRoute, +}); + +function LintRoute() { + return ; +} diff --git a/apps/example/src/routes/docs/llm.tsx b/apps/example/src/routes/docs/llm.tsx new file mode 100644 index 0000000..dd03e64 --- /dev/null +++ b/apps/example/src/routes/docs/llm.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import LlmDoc from "../../../../../docs/llm.mdx"; + +export const Route = createFileRoute("/docs/llm")({ + component: LlmRoute, +}); + +function LlmRoute() { + return ; +} diff --git a/apps/example/src/routes/docs/methodology.tsx b/apps/example/src/routes/docs/methodology.tsx new file mode 100644 index 0000000..78bc9a9 --- /dev/null +++ b/apps/example/src/routes/docs/methodology.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import MethodologyDoc from "../../../../../docs/methodology.mdx"; + +export const Route = createFileRoute("/docs/methodology")({ + component: MethodologyRoute, +}); + +function MethodologyRoute() { + return ; +} diff --git a/apps/example/src/routes/docs/remark.tsx b/apps/example/src/routes/docs/remark.tsx new file mode 100644 index 0000000..f649300 --- /dev/null +++ b/apps/example/src/routes/docs/remark.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import RemarkDoc from "../../../../../docs/remark.mdx"; + +export const Route = createFileRoute("/docs/remark")({ + component: RemarkRoute, +}); + +function RemarkRoute() { + return ; +} diff --git a/apps/docs-smoke/src/routes/docs/route.tsx b/apps/example/src/routes/docs/route.tsx similarity index 100% rename from apps/docs-smoke/src/routes/docs/route.tsx rename to apps/example/src/routes/docs/route.tsx diff --git a/apps/docs-smoke/src/routes/docs/search.tsx b/apps/example/src/routes/docs/search.tsx similarity index 79% rename from apps/docs-smoke/src/routes/docs/search.tsx rename to apps/example/src/routes/docs/search.tsx index a404dcb..bdf81d9 100644 --- a/apps/docs-smoke/src/routes/docs/search.tsx +++ b/apps/example/src/routes/docs/search.tsx @@ -1,7 +1,7 @@ "use client"; import { createFileRoute } from "@tanstack/react-router"; -import SearchDoc from "../../../content/docs/search.mdx"; +import SearchDoc from "../../../../../docs/search.mdx"; export const Route = createFileRoute("/docs/search")({ component: SearchDocsRoute, diff --git a/apps/docs-smoke/src/routes/index.tsx b/apps/example/src/routes/index.tsx similarity index 94% rename from apps/docs-smoke/src/routes/index.tsx rename to apps/example/src/routes/index.tsx index e32d138..796b067 100644 --- a/apps/docs-smoke/src/routes/index.tsx +++ b/apps/example/src/routes/index.tsx @@ -1,13 +1,10 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { ComponentMatrix } from "@/components/component-matrix"; +import { SiteFooter } from "@/components/site-footer"; import { SiteHeader } from "@/components/site-header"; import { navigationRoutes, packageSurfaces } from "@/lib/docs"; -const START_ROUTE_PATHS = new Set([ - "/docs/guides/quickstart", - "/playground", - "/search", -]); +const START_ROUTE_PATHS = new Set(["/docs", "/playground", "/search"]); export const Route = createFileRoute("/")({ component: HomeRoute, @@ -19,9 +16,9 @@ function HomeRoute() { ); return ( -
+
-
+

@@ -66,9 +63,9 @@ function HomeRoute() {

- Quickstart + Open docs
@@ -139,6 +136,7 @@ export const components = {
           
         
       
+      
     
); } diff --git a/apps/docs-smoke/src/routes/playground.tsx b/apps/example/src/routes/playground.tsx similarity index 89% rename from apps/docs-smoke/src/routes/playground.tsx rename to apps/example/src/routes/playground.tsx index 9424ba0..d14b3de 100644 --- a/apps/docs-smoke/src/routes/playground.tsx +++ b/apps/example/src/routes/playground.tsx @@ -9,6 +9,7 @@ import { Tabs, TypeTable, } from "@/components/docs-mdx"; +import { SiteFooter } from "@/components/site-footer"; import { SiteHeader } from "@/components/site-header"; const recipes = { @@ -20,7 +21,7 @@ const recipes = { code: `export const components = { ...mdxComponents, };`, - validation: "bun run --filter docs-smoke test:e2e", + validation: "bun run --filter example test:e2e", }, convert: { title: "Convert For Agents", @@ -33,7 +34,7 @@ import { defaultRemarkPlugins, remarkInclude } from "@inth/docs/remark";`, outDir: "public", remarkPlugins: [remarkInclude, ...defaultRemarkPlugins], });`, - validation: "bun run --filter docs-smoke pipeline:build", + validation: "bun run --filter example pipeline:build", }, search: { title: "Search And Answer", @@ -50,7 +51,7 @@ const { response } = streamDocsAnswer({ model, productName: "@inth/docs", });`, - validation: "bun run --filter docs-smoke pipeline:search", + validation: "bun run --filter example pipeline:search", }, } as const; @@ -66,9 +67,9 @@ export const Route = createFileRoute("/playground")({ function PlaygroundRoute() { return ( -
+
-
+

@@ -99,6 +100,7 @@ function PlaygroundRoute() {

+
); } @@ -157,12 +159,12 @@ function RecipePreview({ activeValue }: { activeValue: string }) { if (activeValue === "convert") { return ( @@ -187,6 +189,7 @@ function RecipePreview({ activeValue }: { activeValue: string }) { /> Open live search diff --git a/apps/example/src/routes/search.tsx b/apps/example/src/routes/search.tsx new file mode 100644 index 0000000..01a06e3 --- /dev/null +++ b/apps/example/src/routes/search.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { createFileRoute } from "@tanstack/react-router"; +import type { FormEvent } from "react"; +import { useEffect, useId } from "react"; +import { Streamdown } from "streamdown"; +import { SiteFooter } from "@/components/site-footer"; +import { SiteHeader } from "@/components/site-header"; +import { SEARCH_MAX_QUERY_LENGTH, useDocsSearch } from "@/lib/use-docs-search"; + +export const Route = createFileRoute("/search")({ + component: SearchRoute, + validateSearch: (search: Record) => ({ + q: typeof search.q === "string" ? search.q : undefined, + }), +}); + +function SearchRoute() { + const inputId = useId(); + const { q: initialQuery } = Route.useSearch(); + const { + answer, + answerConfig, + answerStatus, + askAi, + error, + query, + results, + runSearch, + searchStatus, + setQuery, + } = useDocsSearch(initialQuery ?? "tabs"); + + // Pick up `?q=` deep links from the popover's Cmd+Enter expansion. + // biome-ignore lint/correctness/useExhaustiveDependencies: Only route query changes should replace local input state. + useEffect(() => { + if (initialQuery && initialQuery !== query) { + setQuery(initialQuery); + } + }, [initialQuery]); + + async function handleSearch(event: FormEvent) { + event.preventDefault(); + await runSearch(); + } + + function handleAsk() { + const askPromise = askAi(); + askPromise.catch(() => undefined); + } + + const canAsk = query.trim().length > 0 && answerConfig.enabled; + const isAnswering = + answerStatus === "loading" || answerStatus === "streaming"; + + return ( +
+ +
+
+
+

+ Local index plus optional AI answer +

+

+ Search the docs +

+

+ Typing queries the generated static index through + `/api/docs/search`. The model is called only when the Ask button + is enabled and pressed. Press{" "} + + ⌘K + {" "} + from anywhere to open the inline popover. +

+
+ +
+ + setQuery(event.target.value)} + placeholder="Search docs or ask a question" + value={query} + /> +
+ + +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+

+ Results +

+ {results.length > 0 ? ( + + ) : ( +

+ Type a docs term such as install, tabs, or search. +

+ )} +
+
+ + +
+ +
+ ); +} diff --git a/apps/docs-smoke/src/styles.css b/apps/example/src/styles.css similarity index 97% rename from apps/docs-smoke/src/styles.css rename to apps/example/src/styles.css index b029276..4f2425f 100644 --- a/apps/docs-smoke/src/styles.css +++ b/apps/example/src/styles.css @@ -108,13 +108,16 @@ @apply underline underline-offset-4; } -.docs-prose ul, +.docs-prose ul { + @apply list-disc pl-6; +} + .docs-prose ol { - @apply pl-6; + @apply list-decimal pl-6; } .docs-prose li { - @apply my-2; + @apply my-2 marker:text-muted-foreground/60; } .docs-prose pre { @@ -159,13 +162,16 @@ @apply my-3; } -.docs-answer ul, +.docs-answer ul { + @apply list-disc pl-5; +} + .docs-answer ol { - @apply pl-5; + @apply list-decimal pl-5; } .docs-answer li { - @apply my-1; + @apply my-1 marker:text-muted-foreground/60; } .docs-answer strong { diff --git a/apps/example/tests/e2e/smoke.e2e.ts b/apps/example/tests/e2e/smoke.e2e.ts new file mode 100644 index 0000000..bca5dcc --- /dev/null +++ b/apps/example/tests/e2e/smoke.e2e.ts @@ -0,0 +1,147 @@ +import { expect, type Page, test } from "@playwright/test"; + +const DASHBOARD_HEADING = /Build docs with @inth\/docs/i; +const AI_DISABLED_MESSAGE = /AI answers are disabled/i; +const REMARK_DOCS_URL = /\/docs\/remark/; + +async function waitForClientHydration(page: Page): Promise { + await page.waitForFunction( + () => document.readyState === "complete" && !("$_TSR" in window) + ); +} + +test("home renders the developer dashboard with package surfaces", async ({ + page, + request, +}) => { + const response = await request.get("/"); + const html = await response.text(); + + expect(html).toContain("Build docs with"); + expect(html).toContain("@inth/docs/search/vercel"); + + await page.goto("/", { waitUntil: "networkidle" }); + await expect(page.getByText(DASHBOARD_HEADING)).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Package surfaces", exact: true }) + ).toBeVisible(); +}); + +test("/docs renders the package overview MDX", async ({ page, request }) => { + const response = await request.get("/docs"); + const html = await response.text(); + + expect(html).toContain("@inth/docs"); + + await page.goto("/docs", { waitUntil: "networkidle" }); + // Sidebar populated from docs.config.ts sections. + await expect(page.getByRole("link", { name: "Overview" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Components" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Convert" })).toBeVisible(); +}); + +test("/docs/remark renders the remark plugin reference", async ({ + page, + request, +}) => { + const response = await request.get("/docs/remark"); + const html = await response.text(); + + expect(html).toContain("Remark"); + expect(html).toContain("defaultRemarkPlugins"); + expect(html).toContain("PipelineExampleOptions"); + + await page.goto("/docs/remark", { waitUntil: "networkidle" }); + await waitForClientHydration(page); + await expect( + page.getByRole("heading", { name: "Remark", exact: true }) + ).toBeVisible(); +}); + +test("/docs/search renders the search APIs reference", async ({ + page, + request, +}) => { + const response = await request.get("/docs/search"); + const html = await response.text(); + + expect(html).toContain("@inth/docs/search"); + + await page.goto("/docs/search", { waitUntil: "networkidle" }); + await expect( + page.getByRole("link", { name: "Search", exact: true }) + ).toBeVisible(); + await expect( + page.getByRole("table").filter({ hasText: "@inth/docs/search/vercel" }) + ).toBeVisible(); +}); + +test("/search returns local docs results and answer configuration", async ({ + page, + request, +}) => { + const answerConfigResponse = await request.get("/api/docs/ask"); + const answerConfig = (await answerConfigResponse.json()) as { + enabled: boolean; + }; + + await page.goto("/search", { waitUntil: "networkidle" }); + await waitForClientHydration(page); + await expect( + page.getByRole("heading", { name: "Search the docs", exact: true }) + ).toBeVisible(); + + const searchResponse = page.waitForResponse( + (response) => + response.url().includes("/api/docs/search?q=convert") && response.ok() + ); + await page.getByLabel("Search query").fill("convert"); + await searchResponse; + await expect(page.getByRole("heading", { name: "Results" })).toBeVisible(); + + if (!answerConfig.enabled) { + await expect(page.getByText(AI_DISABLED_MESSAGE)).toBeVisible(); + } +}); + +test("/api/docs/search returns JSON results for a known term", async ({ + request, +}) => { + const response = await request.get("/api/docs/search?q=convert"); + + expect(response.ok()).toBe(true); + const data = (await response.json()) as { + results: Array<{ title: string; urlPath: string }>; + }; + expect(data.results.length).toBeGreaterThan(0); +}); + +test("Cmd+K search popover opens, queries, and navigates", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }); + await waitForClientHydration(page); + + // Open via keyboard shortcut. + await page.keyboard.press("Meta+k"); + const popover = page.getByRole("dialog", { name: "Search docs" }); + await expect(popover).toBeVisible(); + + // Type a query that maps to a known doc. + const searchResponse = page.waitForResponse( + (response) => + response.url().includes("/api/docs/search?q=remark") && response.ok() + ); + await popover.getByLabel("Search query").fill("remark"); + await searchResponse; + + // Click the first result and verify navigation. + const firstResult = popover.locator("ul li a").first(); + await expect(firstResult).toBeVisible(); + await firstResult.click(); + await expect(page).toHaveURL(REMARK_DOCS_URL); +}); + +test("playground route renders the recipes panel", async ({ page }) => { + await page.goto("/playground", { waitUntil: "networkidle" }); + await waitForClientHydration(page); + await expect(page.getByText("Recipes playground")).toBeVisible(); +}); diff --git a/apps/docs-smoke/tsconfig.json b/apps/example/tsconfig.json similarity index 100% rename from apps/docs-smoke/tsconfig.json rename to apps/example/tsconfig.json diff --git a/apps/docs-smoke/type-fixtures/pipeline-example.ts b/apps/example/type-fixtures/pipeline-example.ts similarity index 100% rename from apps/docs-smoke/type-fixtures/pipeline-example.ts rename to apps/example/type-fixtures/pipeline-example.ts diff --git a/apps/docs-smoke/vite.config.ts b/apps/example/vite.config.ts similarity index 90% rename from apps/docs-smoke/vite.config.ts rename to apps/example/vite.config.ts index e035cd6..d94236b 100644 --- a/apps/docs-smoke/vite.config.ts +++ b/apps/example/vite.config.ts @@ -5,6 +5,7 @@ import viteReact from "@vitejs/plugin-react"; import type { Root } from "mdast"; import { nitro } from "nitro/vite"; import remarkFrontmatter from "remark-frontmatter"; +import remarkGfm from "remark-gfm"; import { defineConfig, searchForWorkspaceRoot } from "vite"; import viteTsConfigPaths from "vite-tsconfig-paths"; @@ -34,7 +35,7 @@ export default defineConfig({ { ...mdx({ providerImportSource: "@mdx-js/react", - remarkPlugins: [remarkFrontmatter, stripYamlFrontmatter], + remarkPlugins: [remarkFrontmatter, remarkGfm, stripYamlFrontmatter], }), enforce: "pre", }, diff --git a/biome.jsonc b/biome.jsonc index 22bacbd..523a3bd 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -18,7 +18,7 @@ "!**/public", "!**/public-real", "!**/routeTree.gen.ts", - "!apps/docs-smoke/src/generated/docs-search-*.json" + "!apps/example/src/generated/docs-search-*.json" ] }, "overrides": [ diff --git a/bun.lock b/bun.lock index 75d1c06..28e8fec 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,8 @@ "@biomejs/biome": "2.4.12", "@changesets/changelog-github": "0.6.0", "@changesets/cli": "2.30.0", + "@inth/docs": "workspace:*", + "@mdx-js/react": "^3.1.1", "husky": "^9.1.7", "prettier": "^3.7.4", "turbo": "^2.9.6", @@ -15,8 +17,8 @@ "ultracite": "7.6.0", }, }, - "apps/docs-smoke": { - "name": "docs-smoke", + "apps/example": { + "name": "example", "version": "0.0.0", "dependencies": { "@fontsource-variable/geist": "^5.2.8", @@ -49,6 +51,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.2.0", "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", "typescript": "5.9.2", "vite": "^7.3.1", "vite-tsconfig-paths": "^5.1.4", @@ -951,8 +954,6 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], - "docs-smoke": ["docs-smoke@workspace:apps/docs-smoke"], - "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -1011,6 +1012,8 @@ "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + "example": ["example@workspace:apps/example"], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], diff --git a/packages/docs/agent-docs-src/docs/components.mdx b/docs/components.mdx similarity index 95% rename from packages/docs/agent-docs-src/docs/components.mdx rename to docs/components.mdx index 01a444b..f4ea62e 100644 --- a/packages/docs/agent-docs-src/docs/components.mdx +++ b/docs/components.mdx @@ -1,6 +1,7 @@ --- title: "Components" description: "How to define app-owned MDX components that the @inth/docs pipeline can flatten." +group: components --- # Components @@ -11,7 +12,7 @@ The package only assumes a small authoring contract for MDX component names so t ## App-Owned Adapter Map -Define your own `mdxComponents` map in the docs app. The smoke app keeps its example implementation under `apps/docs-smoke/src/components/docs-mdx`. +Define your own `mdxComponents` map in the docs app. The example app keeps its implementation under `apps/example/src/components/docs-mdx`. Common component names handled by the default remark pipeline include: @@ -66,7 +67,7 @@ Use for package-manager-specific install or run commands. ```tsx - + ` | Source directory to scan. Equivalent to the positional argument. | +| `--changelog ` | Subdirectory under the source root that should use the changelog schema. | +| `--format ` | Report format: `pretty`, `json`, or `github`. Defaults to `pretty`. | +| `--ignore ` | Docs-root-relative glob to skip. Repeatable. | +| `--warn-unknown` | Report unknown frontmatter and meta fields as warnings. This is the default. | +| `--error-unknown` | Report unknown frontmatter and meta fields as errors. | +| `--max-warnings ` | Exit non-zero when warning count is greater than `n`. | +| `-h`, `--help` | Print CLI usage. | + +Default ignored paths are `**/shared/**`, `**/_shared/**`, +`**/_partials/**`, and `**/node_modules/**`. When you pass `--ignore`, provide +each glob you want the run to use. + +Exit code `0` means no errors and warnings stayed within `--max-warnings`. +Exit code `1` means lint errors were found or warnings exceeded the limit. Exit +code `2` means CLI usage failed. + +## Library Usage + +```ts +const result = await lintDocs({ + srcDir: "docs", + unknownFieldSeverity: "error", +}); +``` + +### Options + +| Option | Description | +| --- | --- | +| `srcDir` | Required root containing `.md`, `.mdx`, and `meta.json` files. | +| `changelogDir` | Optional subdirectory that should use the changelog schema. | +| `ignore` | Glob patterns relative to `srcDir`. Defaults to shared fragments, partials, and `node_modules`. | +| `unknownFieldSeverity` | `warn` or `error` for fields not present in the active schema. Defaults to `warn`. | +| `schemas.frontmatter` | Custom Valibot object schema for docs page frontmatter. | +| `schemas.changelogFrontmatter` | Custom Valibot object schema for changelog frontmatter. | +| `schemas.meta` | Custom Valibot object schema for `meta.json`. | + +The result contains file-level violations plus summary counts: + +```ts +type LintResult = { + violations: Array<{ + file: string; + kind: "frontmatter" | "changelog" | "meta" | "content"; + severity: "error" | "warn"; + rule: + | "schema" + | "unknown-field" + | "parse-error" + | "invalid-link" + | "unresolved-placeholder" + | "cross-framework-link"; + field?: string; + message: string; + }>; + summary: { + filesScanned: number; + errors: number; + warnings: number; + }; +}; +``` + +Required-field failures are reported as `schema` violations. Treat that as the +public rule for missing or invalid schema fields. + +## Rule Reference + +| Rule | Severity | Meaning | +| --- | --- | --- | +| `schema` | error | A value failed the active frontmatter, changelog, or `meta.json` schema. | +| `unknown-field` | warn by default | A top-level field is not in the active schema and is not read by the default consumers. | +| `parse-error` | error | Frontmatter, `meta.json`, or rendered markdown could not be parsed for validation. | +| `invalid-link` | error | A `/docs/...` link points to a route that does not exist in the source docs tree. | +| `unresolved-placeholder` | error | A docs URL still contains an unresolved placeholder such as `{framework}`. | +| `cross-framework-link` | error | A framework-scoped page links to a different framework's docs route. | + +## Default Schemas + +### Docs Frontmatter + +| Field | Required | Type | +| --- | --- | --- | +| `title` | Yes | non-empty string | +| `description` | No | string | +| `icon` | No | string | +| `deprecated` | No | boolean | +| `deprecatedReason` | No | string | +| `experimental` | No | boolean | +| `canary` | No | boolean | +| `new` | No | boolean | +| `draft` | No | boolean | +| `tags` | No | string array | +| `group` | No | string or string array | +| `availableIn` | No | array of `{ framework, url?, title? }` | +| `full` | No | boolean | + +`lastModified` and `lastAuthor` are generated by conversion when +`enrichFrontmatterFromGit` is enabled. Do not author them in source docs. + +### Changelog Frontmatter + +| Field | Required | Type | +| --- | --- | --- | +| `title` | Yes | non-empty string | +| `version` | Yes | SemVer string | +| `date` | Yes | ISO-8601 or parseable date string | +| `description` | No | string | +| `icon` | No | string | +| `type` | No | `release`, `improvement`, `retired`, or `deprecation` | +| `tags` | No | string array | +| `canary` | No | boolean | +| `authors` | No | string or string array | +| `draft` | No | boolean | + +### `meta.json` + +| Field | Required | Type | +| --- | --- | --- | +| `pages` | Yes | string array | +| `title` | No | non-empty string | +| `root` | No | boolean | +| `icon` | No | string | +| `defaultOpen` | No | boolean | +| `nav.sidebar` | No | `section` or `combined` | +| `nav.label` | No | string | +| `nav.mode` | No | string | + +## Guidance + +- Run lint before `@inth/docs generate` so content errors fail before artifacts + are written. +- Use `--format json` for automation and `--format github` for GitHub Actions + annotations. +- Treat unresolved placeholder errors as content bugs first. +- If lint fails after a docs move, check `meta.json` and internal links + together; they usually drift at the same time. diff --git a/packages/docs/agent-docs-src/docs/llm.mdx b/docs/llm.mdx similarity index 99% rename from packages/docs/agent-docs-src/docs/llm.mdx rename to docs/llm.mdx index 66cbdf3..18ae407 100644 --- a/packages/docs/agent-docs-src/docs/llm.mdx +++ b/docs/llm.mdx @@ -1,6 +1,7 @@ --- title: "LLM" description: "How to generate llms.txt and topic-scoped full-context files from @inth/docs." +group: llm --- # LLM diff --git a/docs/methodology.mdx b/docs/methodology.mdx new file mode 100644 index 0000000..3f26b9c --- /dev/null +++ b/docs/methodology.mdx @@ -0,0 +1,51 @@ +--- +title: "Methodology" +description: "How @inth/docs differs from Fumadocs, Mintlify, and Starlight" +group: overview +--- + +# Methodology + +`@inth/docs` is not a docs website framework. It is the shared pipeline behind docs websites. + +We built it at Inth because projects like [c15t](https://c15t.com) and [dsar-sdk](https://dsar-sdk.dev) need one consistent docs system across packages, repos, websites, generated markdown, `llms.txt`, search, and package-bundled agent docs. + +The browser UI stays owned by your app. `@inth/docs` makes the content portable. + +## The short version + +- Fumadocs helps you build a React docs site. +- Starlight helps you build an Astro docs site. +- Mintlify gives you a hosted docs platform. +- `@inth/docs` helps you reuse the same docs content across sites, packages, search, and agents. + +Use the others when the main job is publishing a polished website quickly. Use `@inth/docs` when the main job is standardizing the docs infrastructure behind one or more websites. + +## What it does + +`@inth/docs` gives you framework-neutral tools to: + +- Convert MDX-heavy docs into clean markdown. +- Generate `llms.txt` and full-context topic files. +- Build static search artifacts. +- Validate frontmatter, navigation metadata, and links. +- Ship agent-readable docs inside published packages. + +It does not provide the visual docs UI, routing, theme, or hosted deployment. + +## Unified docs across repos + +This is useful when an organization has many packages but wants one docs experience. + +Each repo can keep its MDX next to the code it documents. A shared or private docs UI can render that content with the organization's design system, while `@inth/docs` handles conversion, linting, search data, and agent-doc generation. + +That gives every repo the same output contract without forcing every repo to own the same website implementation. + +## When to use what + +- Use Fumadocs for a composable React docs framework. +- Use Starlight for an Astro-first docs site with strong defaults. +- Use Mintlify for hosted docs with deployment, analytics, API docs, and AI features managed for you. +- Use `@inth/docs` when you own the docs app but need shared infrastructure across repos, packages, generated markdown, search, and agent-facing docs. + +These tools can also be used together: use a docs framework or hosted platform for the browser experience, and use `@inth/docs` for portable docs outputs behind it. diff --git a/packages/docs/agent-docs-src/docs/remark.mdx b/docs/remark.mdx similarity index 81% rename from packages/docs/agent-docs-src/docs/remark.mdx rename to docs/remark.mdx index c07221a..0c86e88 100644 --- a/packages/docs/agent-docs-src/docs/remark.mdx +++ b/docs/remark.mdx @@ -1,6 +1,7 @@ --- title: "Remark" description: "Reference for the remark plugins and default plugin pipeline exported by @inth/docs." +group: remark --- # Remark @@ -51,7 +52,14 @@ Place it before the default plugins so included content is expanded before JSX f ### `remarkTypeTableToMarkdown` -This plugin can be used directly when you only need type-table extraction and not the full pipeline. +This plugin can be used directly when you only need type-table extraction and not the full pipeline. It pairs with `` in MDX, which references a TypeScript file and a type name; the plugin reads the file at build time and renders a markdown table. + +Live example — extracted from `./apps/example/type-fixtures/pipeline-example.ts`: + + ## Plugin Selection Guide diff --git a/packages/docs/agent-docs-src/docs/search.mdx b/docs/search.mdx similarity index 99% rename from packages/docs/agent-docs-src/docs/search.mdx rename to docs/search.mdx index 5f9fdeb..608908a 100644 --- a/packages/docs/agent-docs-src/docs/search.mdx +++ b/docs/search.mdx @@ -1,6 +1,7 @@ --- title: Search description: Generate and query a static docs search index, then stream source-grounded AI answers. +group: search --- # Search diff --git a/package.json b/package.json index fc09826..8ae093d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "@biomejs/biome": "2.4.12", "@changesets/changelog-github": "0.6.0", "@changesets/cli": "2.30.0", + "@inth/docs": "workspace:*", + "@mdx-js/react": "^3.1.1", "husky": "^9.1.7", "prettier": "^3.7.4", "turbo": "^2.9.6", @@ -12,8 +14,7 @@ "name": "suva", "scripts": { "build": "turbo run build", - "dev": "turbo run dev", - "demo:dev": "turbo run dev --filter=@inth/docs --filter=docs-smoke", + "dev": "turbo run dev --filter=example", "lint": "turbo run lint", "format": "prettier --write \"**/*.{ts,tsx,md}\"", "check-types": "turbo run check-types", diff --git a/packages/docs/.gitignore b/packages/docs/.gitignore new file mode 100644 index 0000000..bd06996 --- /dev/null +++ b/packages/docs/.gitignore @@ -0,0 +1,9 @@ +# Generated docs output. The MDX source lives at the repo-root /docs folder; +# `bun run docs:generate` (run as part of the package build) reads from there +# and writes converted markdown + LLM bundles into /docs here. The folder +# ships in the published tarball; nothing in it is committed. +/docs/ +# Root-level byproducts of the llm helper. Not published (not in package.json +# `files`); just regenerated alongside the docs/ output. +/llms.txt +/llms-full.txt diff --git a/packages/docs/README.md b/packages/docs/README.md index 8c2558a..56fd7e9 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -12,7 +12,7 @@ Framework-neutral docs pipeline tooling for Inth docs projects. - `@inth/docs/search/vercel`: Vercel AI Gateway / AI SDK answer streaming and bash tools - `@inth/docs/search/tanstack`: TanStack AI answer streaming and bash tools - `@inth/docs/search/cloudflare`: Cloudflare AI Gateway / Workers AI adapter helpers and bash tools -- `@inth/docs/lint`: docs validation and the `inth-docs-lint` CLI +- `@inth/docs/lint`: docs validation and the `@inth/docs lint` CLI ## Install @@ -40,8 +40,8 @@ await convertAllMdx({ This package is verified in three distinct layers: - Package unit tests in `packages/docs/src/**/*.test.ts*` cover pure library behavior such as conversion, search, linting, and generated docs output. -- Pipeline fixtures in `apps/docs-smoke/scripts` and `apps/docs-smoke/content` exercise MDX conversion, LLM generation, and `ExtractedTypeTable`. -- The live consumer demo in `apps/docs-smoke` owns and renders its MDX components inside a TanStack Start app and provides Playwright browser coverage. +- Pipeline fixtures in `apps/example/scripts` and `apps/example/content` exercise MDX conversion, LLM generation, and `ExtractedTypeTable`. +- The live consumer demo in `apps/example` owns and renders its MDX components inside a TanStack Start app and provides Playwright browser coverage. Use the demo app as the reference integration when you need to see how a consumer should host and style the package in practice. @@ -66,25 +66,25 @@ Do not add `@inth/docs` to product runtime code unless that runtime also renders ## Generate Agent Docs -Run: +The MDX source for this package's docs lives at the repo root in `/docs` (with `meta.json`). Run: ```bash -INTH_DOCS_AGENT_BASE_URL=https://docs.example.com/@inth/docs bun run docs:agent +INTH_DOCS_AGENT_BASE_URL=https://docs.example.com/@inth/docs bun run docs:generate ``` -This writes a packaged reference bundle into `agent-docs/`. +This reads `/docs/*.mdx` and writes converted markdown plus `llms*.txt` bundles into `packages/docs/docs/`. The output folder is gitignored and produced fresh at build time; only the converted output ships in the published tarball — the `.mdx` source does not. ## Bundled Agent References The published package includes: -- `agent-docs/docs/llms.txt` -- `agent-docs/docs/components.md` -- `agent-docs/docs/convert.md` -- `agent-docs/docs/remark.md` -- `agent-docs/docs/llm.md` -- `agent-docs/docs/search.md` -- `agent-docs/docs/lint.md` +- `docs/llms.txt` +- `docs/components.md` +- `docs/convert.md` +- `docs/remark.md` +- `docs/llm.md` +- `docs/search.md` +- `docs/lint.md` These files are intended for coding agents and other tooling that need small, topic-scoped references instead of a full docs site. diff --git a/packages/docs/agent-docs-src/docs/lint.mdx b/packages/docs/agent-docs-src/docs/lint.mdx deleted file mode 100644 index 847a047..0000000 --- a/packages/docs/agent-docs-src/docs/lint.mdx +++ /dev/null @@ -1,60 +0,0 @@ ---- -title: "Lint" -description: "How to validate docs content with lintDocs and the inth-docs-lint CLI." ---- - -# Lint - -Import the library API from: - -```ts -import { lintDocs } from "@inth/docs/lint"; -``` - -Run the CLI with: - -```bash -inth-docs-lint docs -``` - -## What It Checks - -- Frontmatter schema validation -- Changelog schema validation when configured -- `meta.json` structure -- Broken `/docs/...` links -- Unresolved framework placeholders in docs URLs -- Missing target routes after conversion-aware link resolution - -## Library Usage - -```ts -const result = await lintDocs({ - srcDir: "docs", -}); -``` - -The result contains file-level findings plus summary counts for errors and warnings. - -## Common Options - -- `srcDir`: docs root to scan. -- `changelogDir`: optional changelog directory. -- `schemas`: override frontmatter or changelog schemas. -- `ignoreGlobs`: exclude generated or vendored content. - -## CLI Notes - -The CLI is exposed as `inth-docs-lint`. It is intended for CI and local content checks. - -Use it when: - -- validating a docs PR -- checking that `/docs/...` links still resolve -- enforcing frontmatter shape without spinning up the site - -## Guidance - -- Point linting at source docs, not generated markdown. -- Treat unresolved placeholder errors as content bugs first, not renderer bugs. -- If lint fails after a docs move, check `meta.json` and internal links together; they usually drift at the same time. diff --git a/packages/docs/agent-docs/docs/components.md b/packages/docs/agent-docs/docs/components.md deleted file mode 100644 index aa69beb..0000000 --- a/packages/docs/agent-docs/docs/components.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -title: Components -description: >- - How to define app-owned MDX components that the @inth/docs pipeline can - flatten. ---- -# Components - -`@inth/docs` does not export prebuilt UI components or a `mdxComponents` map. The consuming docs app owns runtime rendering, styling, accessibility, and framework-specific integration. - -The package only assumes a small authoring contract for MDX component names so the remark pipeline can flatten those components into Markdown for agents, LLM bundles, search indexes, and validation. - -## App-Owned Adapter Map - -Define your own `mdxComponents` map in the docs app. The smoke app keeps its example implementation under `apps/docs-smoke/src/components/docs-mdx`. - -Common component names handled by the default remark pipeline include: - -* `Accordion` -* `AccordionItem` -* `ExtractedTypeTable` -* `Callout` -* `Card` -* `Cards` -* `Example` -* `Mermaid` -* `CommandTabs` -* `Selector` -* `Step` -* `Steps` -* `Tab` -* `Tabs` -* `TopicSwitcher` -* `TypeTable` - -Use it like this: - -```tsx -import { mdxComponents } from "@/components/docs-mdx"; - -const components = { - ...mdxComponents, -}; -``` - -If your app uses different names, add custom remark plugins or adapter components that map authored MDX back to the names handled by the pipeline. - -## Important Components - -### `Accordion` and `AccordionItem` - -Use for secondary details that should be collapsible in the browser but still available to markdown conversion, search, and LLM bundles. - -```tsx - - - Use accordions for supporting details, troubleshooting notes, and optional reference material. - - -``` - -Closed content is still flattened by the remark pipeline, so do not use accordions to hide content from generated outputs. - -### `CommandTabs` - -Use for package-manager-specific install or run commands. - -```tsx - - - - -``` - -Use `mode="install"` when `command` is a package name, `mode="run"` when `command` is a CLI name, and `mode="create"` for starter commands such as `pnpm create next-app`. `command` can also include a `{pm}` placeholder for custom templates. Use `commands` for exact per-manager overrides and `defaultManager` to choose the initial tab. - -### `Example` - -Use for data-driven preview and source examples. The app-owned component receives code as data; host apps can add filesystem loaders or dynamic imports outside `@inth/docs` when they need app-specific behavior. - -```tsx - - - The host app owns styling and runtime components while `@inth/docs` owns conversion. - - -``` - -Use `sourceFiles` when an example needs to show supporting files in addition to the primary snippet. - -### `TypeTable` and `ExtractedTypeTable` - -Use `TypeTable` for explicit prop or type rows you already know. Use `ExtractedTypeTable` when the docs should extract types from source files. - -`ExtractedTypeTable` is the most path-sensitive component in the set. If it needs to resolve project files, pair it with the matching remark plugin configuration and set a stable base path. - -### `Tabs`, `Tab`, `Steps`, `Step` - -These component names are primarily authoring affordances in MDX. When the markdown conversion pipeline runs, their content is flattened into standard markdown so agents do not need JSX-aware renderers. - -### `TopicSwitcher` - -Use for reader-facing navigation across equivalent documentation topics. - -```tsx - -``` - -`TopicSwitcher` is intentionally generic. Frameworks are one topic category, but the same component can represent SDKs, runtimes, deployment targets, product areas, or other equivalent slices of docs. It does not automatically read LLM topic config. - -## Guidance - -* Keep runtime components in the docs app. -* Keep component names stable when the conversion pipeline depends on them. -* If the goal is agent-readable markdown, read [Remark](/docs/remark) instead of reimplementing the JSX flattening rules. diff --git a/packages/docs/agent-docs/docs/convert.md b/packages/docs/agent-docs/docs/convert.md deleted file mode 100644 index d33eea4..0000000 --- a/packages/docs/agent-docs/docs/convert.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: Convert -description: How to convert MDX docs into Markdown with @inth/docs/convert. ---- -# Convert - -The `@inth/docs/convert` entrypoint provides three main APIs: - -* `convertMdxToMarkdown` -* `writeMdxFileAsMarkdown` -* `convertAllMdx` - -Import them from: - -```ts -import { - convertAllMdx, - convertMdxToMarkdown, - writeMdxFileAsMarkdown, -} from "@inth/docs/convert"; -``` - -## Main Use Cases - -### Convert one file in memory - -Use `convertMdxToMarkdown` when you need the rendered markdown string plus the resolved frontmatter. - -```ts -const result = await convertMdxToMarkdown( - "docs/guides/quickstart.mdx", - defaultRemarkPlugins, - false -); -``` - -### Convert a single file to disk - -Use `writeMdxFileAsMarkdown` when you already know the source path and output path. - -### Convert an entire docs tree - -Use `convertAllMdx` for batch conversion: - -```ts -await convertAllMdx({ - srcDir: "content", - outDir: "public", - remarkPlugins: defaultRemarkPlugins, - enrichFrontmatterFromGit: true, -}); -``` - -## Important Config - -* `srcDir`: root directory containing `.mdx` files. -* `outDir`: destination for generated `.md` files. -* `remarkPlugins`: additional unified plugins, usually `defaultRemarkPlugins` from `@inth/docs/remark`. -* `enrichFrontmatterFromGit`: adds git-derived metadata when available. - -## Behavior Notes - -* Frontmatter is preserved when present. -* If a file has no frontmatter, the converter synthesizes `title` and sometimes `description` from the rendered markdown. -* Markdown tables and Mermaid blocks are compacted after rendering for cleaner agent consumption. -* Conversion is concurrent and optimized for large doc trees. - -## Recommended Pairing - -In most apps, pair conversion with: - -```ts -import { defaultRemarkPlugins, remarkInclude } from "@inth/docs/remark"; -``` - -Then pass: - -```ts -remarkPlugins: [remarkInclude, ...defaultRemarkPlugins] -``` - -Use `remarkInclude` only when the source docs actually rely on include tags or partial expansion. diff --git a/packages/docs/agent-docs/docs/index.md b/packages/docs/agent-docs/docs/index.md deleted file mode 100644 index 8e5a9fa..0000000 --- a/packages/docs/agent-docs/docs/index.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: '@inth/docs' -description: >- - Reference map for the shared MDX conversion, linting, and LLM doc-generation - package. ---- -# `@inth/docs` - -`@inth/docs` is the shared docs package for Inth properties. It provides: - -* A remark pipeline that flattens MDX components into LLM-friendly markdown. -* MDX to markdown conversion utilities. -* `llms.txt` and topic-scoped `llms-full/*.txt` generators. -* Static docs search, content readers, source-grounded answer helpers, and optional bash-tool integration. -* MDX linting utilities for frontmatter, `meta.json`, and docs links. - -## Package Surfaces - -* [Components](/docs/components): The app-owned MDX component contract used by the remark pipeline. -* [Convert](/docs/convert): `convertMdxToMarkdown`, `writeMdxFileAsMarkdown`, and `convertAllMdx`. -* [Remark](/docs/remark): individual remark plugins plus `defaultRemarkPlugins`. -* [LLM](/docs/llm): `generateLlmsTxt` and `generateLLMFullContextFiles`. -* [Search](/docs/search): `generateDocsSearchFiles`, `searchDocs`, source-grounded answer helpers, and the optional bash adapter. -* [Lint](/docs/lint): `lintDocs` and the `inth-docs-lint` CLI. - -## When To Read Which Page - -* Reach for [Components](/docs/components) when defining app-owned MDX components for authored docs. -* Use [Convert](/docs/convert) when you need markdown output from `.mdx` files. -* Check [Remark](/docs/remark) for custom plugin order or component flattening behavior. -* Open [LLM](/docs/llm) when generating `llms.txt` or topic-scoped full-context bundles. -* See [Search](/docs/search) for static indexing, runtime querying, or grounded answer streaming. -* Use [Lint](/docs/lint) to validate frontmatter, docs URLs, or sidebar metadata. - -## Other Frameworks - -React, Vue, Nuxt, Svelte, Astro, and other stacks can use the conversion, LLM, lint, and search entry points today. Runtime MDX components belong in the consuming docs app, not this package. diff --git a/packages/docs/agent-docs/docs/lint.md b/packages/docs/agent-docs/docs/lint.md deleted file mode 100644 index f5375f7..0000000 --- a/packages/docs/agent-docs/docs/lint.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Lint -description: How to validate docs content with lintDocs and the inth-docs-lint CLI. ---- -# Lint - -Import the library API from: - -```ts -import { lintDocs } from "@inth/docs/lint"; -``` - -Run the CLI with: - -```bash -inth-docs-lint docs -``` - -## What It Checks - -* Frontmatter schema validation -* Changelog schema validation when configured -* `meta.json` structure -* Broken `/docs/...` links -* Unresolved framework placeholders in docs URLs -* Missing target routes after conversion-aware link resolution - -## Library Usage - -```ts -const result = await lintDocs({ - srcDir: "docs", -}); -``` - -The result contains file-level findings plus summary counts for errors and warnings. - -## Common Options - -* `srcDir`: docs root to scan. -* `changelogDir`: optional changelog directory. -* `schemas`: override frontmatter or changelog schemas. -* `ignoreGlobs`: exclude generated or vendored content. - -## CLI Notes - -The CLI is exposed as `inth-docs-lint`. It is intended for CI and local content checks. - -Use it when: - -* validating a docs PR -* checking that `/docs/...` links still resolve -* enforcing frontmatter shape without spinning up the site - -## Guidance - -* Point linting at source docs, not generated markdown. -* Treat unresolved placeholder errors as content bugs first, not renderer bugs. -* If lint fails after a docs move, check `meta.json` and internal links together; they usually drift at the same time. diff --git a/packages/docs/agent-docs/docs/llm.md b/packages/docs/agent-docs/docs/llm.md deleted file mode 100644 index e770e37..0000000 --- a/packages/docs/agent-docs/docs/llm.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: LLM -description: How to generate llms.txt and topic-scoped full-context files from @inth/docs. ---- -# LLM - -Import from: - -```ts -import { - generateLLMFullContextFiles, - generateLlmsTxt, -} from "@inth/docs/llm"; -``` - -This surface reads source docs and generated markdown to produce agent-friendly indexes and deep-context bundles. - -## Output Model - -### `generateLlmsTxt` - -Creates: - -* `/llms.txt` -* `/docs/llms.txt` when `docsSections` is provided - -Use it to publish a short product summary plus a curated docs map. - -### `generateLLMFullContextFiles` - -Creates: - -* `/llms-full.txt` -* `/docs/llms-full.txt` -* `/docs/llms-full/*.txt` topic files - -Use it after markdown conversion. It reads `.md` files under `{outDir}/docs/`. - -## Required Conventions - -* Source docs for summaries live under `{srcDir}/docs/`. -* Converted markdown for full files lives under `{outDir}/docs/`. -* Run `convertAllMdx` before `generateLLMFullContextFiles`. - -## Typical Sequence - -```ts -await convertAllMdx({ - srcDir, - outDir, - remarkPlugins: [remarkInclude, ...defaultRemarkPlugins], -}); - -await generateLlmsTxt({ - srcDir, - outDir, - baseUrl, - product: { - name: "My Docs", - summary: "Short product summary.", - }, - docsSections: [ - { - title: "Guides", - links: [{ urlPath: "/docs/guides/quickstart" }], - }, - ], -}); - -await generateLLMFullContextFiles({ - outDir, - baseUrl, - product: { name: "My Docs" }, - topics: [ - { - slug: "guides", - title: "Guides", - description: "Full context for guides.", - includePrefixes: ["guides/"], - }, - ], -}); -``` - -## Topic Design - -Prefer multiple narrow topics over one giant full-context file. - -* Good: `frameworks`, `self-host`, `integrations` -* Poor: one catch-all topic for the whole docs tree - -The APIs support nested routers, so parent topics can point to smaller child topics. - -## Guidance - -* Keep curated summary links opinionated. They should help an agent choose the smallest useful file. -* Write short, explicit descriptions for topics and sections. Those descriptions become routing hints. -* If generated files are empty, check that the docs really live under the expected `docs/` folder names. diff --git a/packages/docs/agent-docs/docs/llms-full.txt b/packages/docs/agent-docs/docs/llms-full.txt deleted file mode 100644 index 649be76..0000000 --- a/packages/docs/agent-docs/docs/llms-full.txt +++ /dev/null @@ -1,15 +0,0 @@ -# @inth/docs Documentation Full Context - -> Choose the smallest topic file that matches the task. - -## Topics - -- [Overview](./llms-full/overview.txt): Package scope and route-selection guidance. -- [Authoring](./llms-full/authoring.txt): MDX rendering components and remark pipeline details. - - [Components](./llms-full/authoring/components.txt): React MDX component adapters. - - [Remark](./llms-full/authoring/remark.txt): Default plugins and conversion helpers. -- [Generation](./llms-full/generation.txt): MDX conversion and llms.txt generation. - - [Convert](./llms-full/generation/convert.txt): MDX-to-markdown conversion APIs. - - [LLM](./llms-full/generation/llm.txt): Summary and full-context file generation. - - [Search](./llms-full/generation/search.txt): Static search indexes and AI answer helpers. -- [Validation](./llms-full/validation.txt): Docs linting and CLI usage. \ No newline at end of file diff --git a/packages/docs/agent-docs/docs/llms-full/authoring.txt b/packages/docs/agent-docs/docs/llms-full/authoring.txt deleted file mode 100644 index 2865eac..0000000 --- a/packages/docs/agent-docs/docs/llms-full/authoring.txt +++ /dev/null @@ -1,8 +0,0 @@ -# @inth/docs Authoring Full Context - -> MDX rendering components and remark pipeline details. - -## Topics - -- [Components](./authoring/components.txt): React MDX component adapters. -- [Remark](./authoring/remark.txt): Default plugins and conversion helpers. \ No newline at end of file diff --git a/packages/docs/agent-docs/docs/llms-full/authoring/components.txt b/packages/docs/agent-docs/docs/llms-full/authoring/components.txt deleted file mode 100644 index b8cabca..0000000 --- a/packages/docs/agent-docs/docs/llms-full/authoring/components.txt +++ /dev/null @@ -1,156 +0,0 @@ -# @inth/docs Components Full Context - -> React MDX component adapters. - -## Included Pages - -- [Components](https://example.invalid/@inth/docs/docs/components): How to define app-owned MDX components that the @inth/docs pipeline can flatten. - -## Content - -# Components -URL: https://example.invalid/@inth/docs/docs/components -How to define app-owned MDX components that the @inth/docs pipeline can flatten. - -# Components - -`@inth/docs` does not export prebuilt UI components or a `mdxComponents` map. The consuming docs app owns runtime rendering, styling, accessibility, and framework-specific integration. - -The package only assumes a small authoring contract for MDX component names so the remark pipeline can flatten those components into Markdown for agents, LLM bundles, search indexes, and validation. - -## App-Owned Adapter Map - -Define your own `mdxComponents` map in the docs app. The smoke app keeps its example implementation under `apps/docs-smoke/src/components/docs-mdx`. - -Common component names handled by the default remark pipeline include: - -* `Accordion` -* `AccordionItem` -* `ExtractedTypeTable` -* `Callout` -* `Card` -* `Cards` -* `Example` -* `Mermaid` -* `CommandTabs` -* `Selector` -* `Step` -* `Steps` -* `Tab` -* `Tabs` -* `TopicSwitcher` -* `TypeTable` - -Use it like this: - -```tsx -import { mdxComponents } from "@/components/docs-mdx"; - -const components = { - ...mdxComponents, -}; -``` - -If your app uses different names, add custom remark plugins or adapter components that map authored MDX back to the names handled by the pipeline. - -## Important Components - -### `Accordion` and `AccordionItem` - -Use for secondary details that should be collapsible in the browser but still available to markdown conversion, search, and LLM bundles. - -```tsx - - - Use accordions for supporting details, troubleshooting notes, and optional reference material. - - -``` - -Closed content is still flattened by the remark pipeline, so do not use accordions to hide content from generated outputs. - -### `CommandTabs` - -Use for package-manager-specific install or run commands. - -```tsx - - - - -``` - -Use `mode="install"` when `command` is a package name, `mode="run"` when `command` is a CLI name, and `mode="create"` for starter commands such as `pnpm create next-app`. `command` can also include a `{pm}` placeholder for custom templates. Use `commands` for exact per-manager overrides and `defaultManager` to choose the initial tab. - -### `Example` - -Use for data-driven preview and source examples. The app-owned component receives code as data; host apps can add filesystem loaders or dynamic imports outside `@inth/docs` when they need app-specific behavior. - -```tsx - - - The host app owns styling and runtime components while `@inth/docs` owns conversion. - - -``` - -Use `sourceFiles` when an example needs to show supporting files in addition to the primary snippet. - -### `TypeTable` and `ExtractedTypeTable` - -Use `TypeTable` for explicit prop or type rows you already know. Use `ExtractedTypeTable` when the docs should extract types from source files. - -`ExtractedTypeTable` is the most path-sensitive component in the set. If it needs to resolve project files, pair it with the matching remark plugin configuration and set a stable base path. - -### `Tabs`, `Tab`, `Steps`, `Step` - -These component names are primarily authoring affordances in MDX. When the markdown conversion pipeline runs, their content is flattened into standard markdown so agents do not need JSX-aware renderers. - -### `TopicSwitcher` - -Use for reader-facing navigation across equivalent documentation topics. - -```tsx - -``` - -`TopicSwitcher` is intentionally generic. Frameworks are one topic category, but the same component can represent SDKs, runtimes, deployment targets, product areas, or other equivalent slices of docs. It does not automatically read LLM topic config. - -## Guidance - -* Keep runtime components in the docs app. -* Keep component names stable when the conversion pipeline depends on them. -* If the goal is agent-readable markdown, read [Remark](/docs/remark) instead of reimplementing the JSX flattening rules. \ No newline at end of file diff --git a/packages/docs/agent-docs/docs/llms-full/authoring/remark.txt b/packages/docs/agent-docs/docs/llms-full/authoring/remark.txt deleted file mode 100644 index 56974c3..0000000 --- a/packages/docs/agent-docs/docs/llms-full/authoring/remark.txt +++ /dev/null @@ -1,75 +0,0 @@ -# @inth/docs Remark Full Context - -> Default plugins and conversion helpers. - -## Included Pages - -- [Remark](https://example.invalid/@inth/docs/docs/remark): Reference for the remark plugins and default plugin pipeline exported by @inth/docs. - -## Content - -# Remark -URL: https://example.invalid/@inth/docs/docs/remark -Reference for the remark plugins and default plugin pipeline exported by @inth/docs. - -# Remark - -Import from: - -```ts -import { - defaultRemarkPlugins, - remarkInclude, - remarkTypeTableToMarkdown, -} from "@inth/docs/remark"; -``` - -## Default Plugin Stack - -`defaultRemarkPlugins` is the standard MDX-to-markdown pipeline for agent docs. - -Order matters. The stack: - -1. Removes MDX imports. -2. Resolves docs placeholders. -3. Flattens JSX-heavy authoring components into plain markdown. - -The default array includes: - -* `remarkRemoveImports` -* `remarkResolveDocPlaceholders` -* `remarkCalloutToMarkdown` -* `remarkCardsToMarkdown` -* `remarkMermaidToMarkdown` -* `remarkCommandTabsToMarkdown` -* `remarkStepsToMarkdown` -* `remarkTabsToMarkdown` -* `remarkTypeTableToMarkdown` - -## When To Add Extra Plugins - -### `remarkInclude` - -Add this when the source docs use include tags or partial composition: - -```ts -remarkPlugins: [remarkInclude, ...defaultRemarkPlugins] -``` - -Place it before the default plugins so included content is expanded before JSX flattening runs. - -### `remarkTypeTableToMarkdown` - -This plugin can be used directly when you only need type-table extraction and not the full pipeline. - -## Plugin Selection Guide - -* Use `defaultRemarkPlugins` for agent or LLM output. -* Add `remarkInclude` when docs are composed from shared fragments. -* Use individual plugins only when a consumer needs a custom order or intentionally omits behavior. - -## Guidance - -* Do not reorder the default stack casually. Import stripping and placeholder resolution need to happen before markdown flattening. -* Keep custom plugins small and place them with intent. Content-shaping plugins usually belong before the default component flatteners. -* If the problem is output quality rather than raw parsing, start by checking the converted markdown from [Convert](/docs/convert) before changing plugin order. \ No newline at end of file diff --git a/packages/docs/agent-docs/docs/llms-full/generation.txt b/packages/docs/agent-docs/docs/llms-full/generation.txt deleted file mode 100644 index 3dad120..0000000 --- a/packages/docs/agent-docs/docs/llms-full/generation.txt +++ /dev/null @@ -1,9 +0,0 @@ -# @inth/docs Generation Full Context - -> MDX conversion and llms.txt generation. - -## Topics - -- [Convert](./generation/convert.txt): MDX-to-markdown conversion APIs. -- [LLM](./generation/llm.txt): Summary and full-context file generation. -- [Search](./generation/search.txt): Static search indexes and AI answer helpers. \ No newline at end of file diff --git a/packages/docs/agent-docs/docs/llms-full/generation/convert.txt b/packages/docs/agent-docs/docs/llms-full/generation/convert.txt deleted file mode 100644 index f20b959..0000000 --- a/packages/docs/agent-docs/docs/llms-full/generation/convert.txt +++ /dev/null @@ -1,92 +0,0 @@ -# @inth/docs Convert Full Context - -> MDX-to-markdown conversion APIs. - -## Included Pages - -- [Convert](https://example.invalid/@inth/docs/docs/convert): How to convert MDX docs into Markdown with @inth/docs/convert. - -## Content - -# Convert -URL: https://example.invalid/@inth/docs/docs/convert -How to convert MDX docs into Markdown with @inth/docs/convert. - -# Convert - -The `@inth/docs/convert` entrypoint provides three main APIs: - -* `convertMdxToMarkdown` -* `writeMdxFileAsMarkdown` -* `convertAllMdx` - -Import them from: - -```ts -import { - convertAllMdx, - convertMdxToMarkdown, - writeMdxFileAsMarkdown, -} from "@inth/docs/convert"; -``` - -## Main Use Cases - -### Convert one file in memory - -Use `convertMdxToMarkdown` when you need the rendered markdown string plus the resolved frontmatter. - -```ts -const result = await convertMdxToMarkdown( - "docs/guides/quickstart.mdx", - defaultRemarkPlugins, - false -); -``` - -### Convert a single file to disk - -Use `writeMdxFileAsMarkdown` when you already know the source path and output path. - -### Convert an entire docs tree - -Use `convertAllMdx` for batch conversion: - -```ts -await convertAllMdx({ - srcDir: "content", - outDir: "public", - remarkPlugins: defaultRemarkPlugins, - enrichFrontmatterFromGit: true, -}); -``` - -## Important Config - -* `srcDir`: root directory containing `.mdx` files. -* `outDir`: destination for generated `.md` files. -* `remarkPlugins`: additional unified plugins, usually `defaultRemarkPlugins` from `@inth/docs/remark`. -* `enrichFrontmatterFromGit`: adds git-derived metadata when available. - -## Behavior Notes - -* Frontmatter is preserved when present. -* If a file has no frontmatter, the converter synthesizes `title` and sometimes `description` from the rendered markdown. -* Markdown tables and Mermaid blocks are compacted after rendering for cleaner agent consumption. -* Conversion is concurrent and optimized for large doc trees. - -## Recommended Pairing - -In most apps, pair conversion with: - -```ts -import { defaultRemarkPlugins, remarkInclude } from "@inth/docs/remark"; -``` - -Then pass: - -```ts -remarkPlugins: [remarkInclude, ...defaultRemarkPlugins] -``` - -Use `remarkInclude` only when the source docs actually rely on include tags or partial expansion. \ No newline at end of file diff --git a/packages/docs/agent-docs/docs/llms-full/generation/llm.txt b/packages/docs/agent-docs/docs/llms-full/generation/llm.txt deleted file mode 100644 index 171ca4a..0000000 --- a/packages/docs/agent-docs/docs/llms-full/generation/llm.txt +++ /dev/null @@ -1,108 +0,0 @@ -# @inth/docs LLM Full Context - -> Summary and full-context file generation. - -## Included Pages - -- [LLM](https://example.invalid/@inth/docs/docs/llm): How to generate llms.txt and topic-scoped full-context files from @inth/docs. - -## Content - -# LLM -URL: https://example.invalid/@inth/docs/docs/llm -How to generate llms.txt and topic-scoped full-context files from @inth/docs. - -# LLM - -Import from: - -```ts -import { - generateLLMFullContextFiles, - generateLlmsTxt, -} from "@inth/docs/llm"; -``` - -This surface reads source docs and generated markdown to produce agent-friendly indexes and deep-context bundles. - -## Output Model - -### `generateLlmsTxt` - -Creates: - -* `/llms.txt` -* `/docs/llms.txt` when `docsSections` is provided - -Use it to publish a short product summary plus a curated docs map. - -### `generateLLMFullContextFiles` - -Creates: - -* `/llms-full.txt` -* `/docs/llms-full.txt` -* `/docs/llms-full/*.txt` topic files - -Use it after markdown conversion. It reads `.md` files under `{outDir}/docs/`. - -## Required Conventions - -* Source docs for summaries live under `{srcDir}/docs/`. -* Converted markdown for full files lives under `{outDir}/docs/`. -* Run `convertAllMdx` before `generateLLMFullContextFiles`. - -## Typical Sequence - -```ts -await convertAllMdx({ - srcDir, - outDir, - remarkPlugins: [remarkInclude, ...defaultRemarkPlugins], -}); - -await generateLlmsTxt({ - srcDir, - outDir, - baseUrl, - product: { - name: "My Docs", - summary: "Short product summary.", - }, - docsSections: [ - { - title: "Guides", - links: [{ urlPath: "/docs/guides/quickstart" }], - }, - ], -}); - -await generateLLMFullContextFiles({ - outDir, - baseUrl, - product: { name: "My Docs" }, - topics: [ - { - slug: "guides", - title: "Guides", - description: "Full context for guides.", - includePrefixes: ["guides/"], - }, - ], -}); -``` - -## Topic Design - -Prefer multiple narrow topics over one giant full-context file. - -* Good: `frameworks`, `self-host`, `integrations` -* Poor: one catch-all topic for the whole docs tree - -The APIs support nested routers, so parent topics can point to smaller child topics. - -## Guidance - -* Keep curated summary links opinionated. They should help an agent choose the smallest useful file. -* Write short, explicit descriptions for topics and sections. Those descriptions become routing hints. -* If generated files are empty, check that the docs really live under the expected `docs/` folder names. \ No newline at end of file diff --git a/packages/docs/agent-docs/docs/llms-full/generation/search.txt b/packages/docs/agent-docs/docs/llms-full/generation/search.txt deleted file mode 100644 index 179bc38..0000000 --- a/packages/docs/agent-docs/docs/llms-full/generation/search.txt +++ /dev/null @@ -1,231 +0,0 @@ -# @inth/docs Search Full Context - -> Static search indexes and AI answer helpers. - -## Included Pages - -- [Search](https://example.invalid/@inth/docs/docs/search): Generate and query a static docs search index, then stream source-grounded AI answers. - -## Content - -# Search -URL: https://example.invalid/@inth/docs/docs/search -Generate and query a static docs search index, then stream source-grounded AI answers. - -# Search - -Import runtime helpers from: - -```ts -import { - createAnswerContext, - createMemoryRateLimiter, - type DocsSearchContentStore, - type DocsSearchIndex, - listDocsContentFiles, - readDocsContentChunk, - readDocsContentFile, - readJsonWithLimit, - searchDocs, - validateDocsQuery, -} from "@inth/docs/search"; -``` - -Import the Node-only generator from: - -```ts -import { generateDocsSearchFiles } from "@inth/docs/search/node"; -``` - -Use the provider entrypoints for answer streaming and provider-compatible bash -tools: - -```ts -import { streamDocsAnswer as streamVercelDocsAnswer } from "@inth/docs/search/vercel"; -import { streamDocsAnswer as streamTanStackDocsAnswer } from "@inth/docs/search/tanstack"; -import { streamDocsAnswer as streamCloudflareDocsAnswer } from "@inth/docs/search/cloudflare"; -``` - -|Use case|Import| -|--|--| -|Search, content reads, request guards, and rate limiting|`@inth/docs/search`| -|Vercel AI Gateway / AI SDK answer + bash tools|`@inth/docs/search/vercel`| -|TanStack AI answer + bash tools|`@inth/docs/search/tanstack`| -|Cloudflare AI Gateway / Workers AI answer + bash tools|`@inth/docs/search/cloudflare`| - -## Build-Time Indexing - -Generate the index after converting MDX to markdown: - -```ts -await generateDocsSearchFiles({ - outDir: "public", - baseUrl: "https://docs.example.com", -}); -``` - -The generator reads markdown under `{outDir}/docs` and writes split files by -default: - -```txt -{outDir}/docs/search-index.json -{outDir}/docs/search-content.json -``` - -`search-index.json` uses a compact v2 tuple format for documents, chunks, and -term postings. `search-content.json` stores answer-source text separately. This -keeps search metadata smaller and gives the AI path a precise way to read only -the page or heading chunks it needs. - -## Runtime Search - -The core runtime is edge-safe. Import both generated JSON files and pass content -when you want excerpts: - -```ts -const results = searchDocs(indexJson as DocsSearchIndex, "tabs install", { - content: contentJson as DocsSearchContentStore, -}); -``` - -Search uses normalized tokens, a small stopword list, heading-aware chunks, and -BM25-style ranking. Titles and headings are weighted above body text; code is -searchable with a lower weight. - -## Docs Content Files - -The same generated index also acts as a small virtual docs filesystem: - -```ts -const files = listDocsContentFiles(indexJson as DocsSearchIndex); -const quickstart = readDocsContentFile( - indexJson as DocsSearchIndex, - "guides/quickstart", - contentJson as DocsSearchContentStore -); -const sourceChunk = readDocsContentChunk( - indexJson as DocsSearchIndex, - "chunk-0", - contentJson as DocsSearchContentStore -); -``` - -Use `readDocsContentFile` when a UI or agent wants a whole normalized page. Use -`readDocsContentChunk` when the search result already identifies the relevant -heading chunk. Returned chunks include heading paths, hash URLs, source text, and -metadata needed for citations. - -## Answer Context - -Use `createAnswerContext` when wiring a custom model call: - -```ts -const context = createAnswerContext(indexJson as DocsSearchIndex, query, { - content: contentJson as DocsSearchContentStore, - productName: "My Docs", -}); -``` - -The returned `system` and `prompt` instruct the model to answer only from -retrieved docs context, cite sources with `[1]` style citations, treat docs text -as untrusted reference content, and say when context is insufficient. - -## Vercel AI Gateway / AI SDK Streaming - -Use `streamDocsAnswer` for a minimal Vercel AI SDK integration: - -```ts -import { streamDocsAnswer } from "@inth/docs/search/vercel"; - -const { response, sources } = streamDocsAnswer({ - index: indexJson as DocsSearchIndex, - content: contentJson as DocsSearchContentStore, - query, - model: process.env.DOCS_SEARCH_MODEL ?? "openai/gpt-5.4-mini", - productName: "My Docs", -}); -``` - -`streamDocsAnswer` returns a plain text `Response`. Display `sources` -separately in your own UI; they are metadata for source links, not embedded in -the streamed answer. - -## BYO Gateway Streaming - -TanStack callers pass the adapter explicitly: - -```ts -import { streamDocsAnswer } from "@inth/docs/search/tanstack"; - -const { response, sources } = streamDocsAnswer({ - index: indexJson as DocsSearchIndex, - content: contentJson as DocsSearchContentStore, - query, - adapter, -}); -``` - -Cloudflare callers use explicit provider mapping: - -```ts -import { - createCloudflareDocsAdapter, - streamDocsAnswer, -} from "@inth/docs/search/cloudflare"; - -const adapter = createCloudflareDocsAdapter({ - provider: "openai", - model: "gpt-4o", - options: { - binding: env.AI.gateway("docs-gateway"), - }, -}); - -const { response, sources } = streamDocsAnswer({ - index: indexJson as DocsSearchIndex, - content: contentJson as DocsSearchContentStore, - query, - adapter, -}); -``` - -## Bash Tool Adapters - -Use the matching provider entrypoint when an agent should inspect docs through -shell commands instead of receiving only preselected chunks: - -```ts -import { createDocsBashTool } from "@inth/docs/search/vercel"; - -const { tools, instructions } = await createDocsBashTool( - indexJson as DocsSearchIndex, - contentJson as DocsSearchContentStore -); -``` - -Vercel wraps the read-only `/docs` filesystem with `bash-tool`. TanStack and -Cloudflare export `createDocsBashTools`, which exposes native TanStack tools over -the same filesystem. Network commands, Python, JavaScript execution, and -filesystem writes are disabled by default. - -## Abuse Guards - -The package includes reusable request-path utilities: - -* `validateDocsQuery` trims and caps query text. -* `readJsonWithLimit` rejects oversized JSON bodies before parsing. -* `getClientIdentifier` reads common proxy IP headers. -* `createMemoryRateLimiter` implements the `RateLimiter` interface for demos. - -In-memory rate limiting is not strong across serverless instances. Production -docs sites should adapt the `RateLimiter` interface to a shared store such as -Redis, Vercel KV, Cloudflare KV, or Durable Objects. - -## When To Use Embeddings - -Start with the local index for most docs sites. It is static, cheap, portable to -Vercel and Cloudflare, and has no request-time database dependency. As docs grow, -keep lexical search for exact APIs, config keys, paths, and errors; add a -virtual content layer for precise page reads; then add embeddings or hosted -search when users need semantic matches that do not share vocabulary with the -docs, or when chunk counts become large enough to hurt cold-start memory. \ No newline at end of file diff --git a/packages/docs/agent-docs/docs/llms-full/overview.txt b/packages/docs/agent-docs/docs/llms-full/overview.txt deleted file mode 100644 index 63032f2..0000000 --- a/packages/docs/agent-docs/docs/llms-full/overview.txt +++ /dev/null @@ -1,45 +0,0 @@ -# @inth/docs Overview Full Context - -> Package scope and route-selection guidance. - -## Included Pages - -- [@inth/docs](https://example.invalid/@inth/docs/docs): Reference map for the shared MDX conversion, linting, and LLM doc-generation package. - -## Content - -# @inth/docs -URL: https://example.invalid/@inth/docs/docs -Reference map for the shared MDX conversion, linting, and LLM doc-generation package. - -# `@inth/docs` - -`@inth/docs` is the shared docs package for Inth properties. It provides: - -* A remark pipeline that flattens MDX components into LLM-friendly markdown. -* MDX to markdown conversion utilities. -* `llms.txt` and topic-scoped `llms-full/*.txt` generators. -* Static docs search, content readers, source-grounded answer helpers, and optional bash-tool integration. -* MDX linting utilities for frontmatter, `meta.json`, and docs links. - -## Package Surfaces - -* [Components](/docs/components): The app-owned MDX component contract used by the remark pipeline. -* [Convert](/docs/convert): `convertMdxToMarkdown`, `writeMdxFileAsMarkdown`, and `convertAllMdx`. -* [Remark](/docs/remark): individual remark plugins plus `defaultRemarkPlugins`. -* [LLM](/docs/llm): `generateLlmsTxt` and `generateLLMFullContextFiles`. -* [Search](/docs/search): `generateDocsSearchFiles`, `searchDocs`, source-grounded answer helpers, and the optional bash adapter. -* [Lint](/docs/lint): `lintDocs` and the `inth-docs-lint` CLI. - -## When To Read Which Page - -* Reach for [Components](/docs/components) when defining app-owned MDX components for authored docs. -* Use [Convert](/docs/convert) when you need markdown output from `.mdx` files. -* Check [Remark](/docs/remark) for custom plugin order or component flattening behavior. -* Open [LLM](/docs/llm) when generating `llms.txt` or topic-scoped full-context bundles. -* See [Search](/docs/search) for static indexing, runtime querying, or grounded answer streaming. -* Use [Lint](/docs/lint) to validate frontmatter, docs URLs, or sidebar metadata. - -## Other Frameworks - -React, Vue, Nuxt, Svelte, Astro, and other stacks can use the conversion, LLM, lint, and search entry points today. Runtime MDX components belong in the consuming docs app, not this package. \ No newline at end of file diff --git a/packages/docs/agent-docs/docs/llms-full/validation.txt b/packages/docs/agent-docs/docs/llms-full/validation.txt deleted file mode 100644 index 69f01af..0000000 --- a/packages/docs/agent-docs/docs/llms-full/validation.txt +++ /dev/null @@ -1,69 +0,0 @@ -# @inth/docs Validation Full Context - -> Docs linting and CLI usage. - -## Included Pages - -- [Lint](https://example.invalid/@inth/docs/docs/lint): How to validate docs content with lintDocs and the inth-docs-lint CLI. - -## Content - -# Lint -URL: https://example.invalid/@inth/docs/docs/lint -How to validate docs content with lintDocs and the inth-docs-lint CLI. - -# Lint - -Import the library API from: - -```ts -import { lintDocs } from "@inth/docs/lint"; -``` - -Run the CLI with: - -```bash -inth-docs-lint docs -``` - -## What It Checks - -* Frontmatter schema validation -* Changelog schema validation when configured -* `meta.json` structure -* Broken `/docs/...` links -* Unresolved framework placeholders in docs URLs -* Missing target routes after conversion-aware link resolution - -## Library Usage - -```ts -const result = await lintDocs({ - srcDir: "docs", -}); -``` - -The result contains file-level findings plus summary counts for errors and warnings. - -## Common Options - -* `srcDir`: docs root to scan. -* `changelogDir`: optional changelog directory. -* `schemas`: override frontmatter or changelog schemas. -* `ignoreGlobs`: exclude generated or vendored content. - -## CLI Notes - -The CLI is exposed as `inth-docs-lint`. It is intended for CI and local content checks. - -Use it when: - -* validating a docs PR -* checking that `/docs/...` links still resolve -* enforcing frontmatter shape without spinning up the site - -## Guidance - -* Point linting at source docs, not generated markdown. -* Treat unresolved placeholder errors as content bugs first, not renderer bugs. -* If lint fails after a docs move, check `meta.json` and internal links together; they usually drift at the same time. \ No newline at end of file diff --git a/packages/docs/agent-docs/docs/llms.txt b/packages/docs/agent-docs/docs/llms.txt deleted file mode 100644 index 313242e..0000000 --- a/packages/docs/agent-docs/docs/llms.txt +++ /dev/null @@ -1,34 +0,0 @@ -# @inth/docs Documentation - -> Curated documentation map for developers and coding agents working with @inth/docs. - -## How To Use This File - -Read the summary links first. If the summary is not enough, choose the smallest relevant topic file from `/docs/llms-full.txt`. - -## Overview - -Start here for package scope and surface selection. - -- [@inth/docs](https://example.invalid/@inth/docs/docs): Reference map for the shared MDX conversion, linting, and LLM doc-generation package. - -## Authoring And Rendering - -React MDX components and remark pipeline behavior. - -- [Components](https://example.invalid/@inth/docs/docs/components): How to define app-owned MDX components that the @inth/docs pipeline can flatten. -- [Remark](https://example.invalid/@inth/docs/docs/remark): Reference for the remark plugins and default plugin pipeline exported by @inth/docs. - -## Generation - -MDX conversion, LLM output generation, and search. - -- [Convert](https://example.invalid/@inth/docs/docs/convert): How to convert MDX docs into Markdown with @inth/docs/convert. -- [LLM](https://example.invalid/@inth/docs/docs/llm): How to generate llms.txt and topic-scoped full-context files from @inth/docs. -- [Search](https://example.invalid/@inth/docs/docs/search): Generate and query a static docs search index, then stream source-grounded AI answers. - -## Validation - -Content validation and link checks. - -- [Lint](https://example.invalid/@inth/docs/docs/lint): How to validate docs content with lintDocs and the inth-docs-lint CLI. \ No newline at end of file diff --git a/packages/docs/agent-docs/docs/remark.md b/packages/docs/agent-docs/docs/remark.md deleted file mode 100644 index df3f90e..0000000 --- a/packages/docs/agent-docs/docs/remark.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Remark -description: >- - Reference for the remark plugins and default plugin pipeline exported by - @inth/docs. ---- -# Remark - -Import from: - -```ts -import { - defaultRemarkPlugins, - remarkInclude, - remarkTypeTableToMarkdown, -} from "@inth/docs/remark"; -``` - -## Default Plugin Stack - -`defaultRemarkPlugins` is the standard MDX-to-markdown pipeline for agent docs. - -Order matters. The stack: - -1. Removes MDX imports. -2. Resolves docs placeholders. -3. Flattens JSX-heavy authoring components into plain markdown. - -The default array includes: - -* `remarkRemoveImports` -* `remarkResolveDocPlaceholders` -* `remarkCalloutToMarkdown` -* `remarkCardsToMarkdown` -* `remarkMermaidToMarkdown` -* `remarkCommandTabsToMarkdown` -* `remarkStepsToMarkdown` -* `remarkTabsToMarkdown` -* `remarkTypeTableToMarkdown` - -## When To Add Extra Plugins - -### `remarkInclude` - -Add this when the source docs use include tags or partial composition: - -```ts -remarkPlugins: [remarkInclude, ...defaultRemarkPlugins] -``` - -Place it before the default plugins so included content is expanded before JSX flattening runs. - -### `remarkTypeTableToMarkdown` - -This plugin can be used directly when you only need type-table extraction and not the full pipeline. - -## Plugin Selection Guide - -* Use `defaultRemarkPlugins` for agent or LLM output. -* Add `remarkInclude` when docs are composed from shared fragments. -* Use individual plugins only when a consumer needs a custom order or intentionally omits behavior. - -## Guidance - -* Do not reorder the default stack casually. Import stripping and placeholder resolution need to happen before markdown flattening. -* Keep custom plugins small and place them with intent. Content-shaping plugins usually belong before the default component flatteners. -* If the problem is output quality rather than raw parsing, start by checking the converted markdown from [Convert](/docs/convert) before changing plugin order. diff --git a/packages/docs/agent-docs/docs/search.md b/packages/docs/agent-docs/docs/search.md deleted file mode 100644 index fb112ee..0000000 --- a/packages/docs/agent-docs/docs/search.md +++ /dev/null @@ -1,223 +0,0 @@ ---- -title: Search -description: >- - Generate and query a static docs search index, then stream source-grounded AI - answers. ---- -# Search - -Import runtime helpers from: - -```ts -import { - createAnswerContext, - createMemoryRateLimiter, - type DocsSearchContentStore, - type DocsSearchIndex, - listDocsContentFiles, - readDocsContentChunk, - readDocsContentFile, - readJsonWithLimit, - searchDocs, - validateDocsQuery, -} from "@inth/docs/search"; -``` - -Import the Node-only generator from: - -```ts -import { generateDocsSearchFiles } from "@inth/docs/search/node"; -``` - -Use the provider entrypoints for answer streaming and provider-compatible bash -tools: - -```ts -import { streamDocsAnswer as streamVercelDocsAnswer } from "@inth/docs/search/vercel"; -import { streamDocsAnswer as streamTanStackDocsAnswer } from "@inth/docs/search/tanstack"; -import { streamDocsAnswer as streamCloudflareDocsAnswer } from "@inth/docs/search/cloudflare"; -``` - -|Use case|Import| -|--|--| -|Search, content reads, request guards, and rate limiting|`@inth/docs/search`| -|Vercel AI Gateway / AI SDK answer + bash tools|`@inth/docs/search/vercel`| -|TanStack AI answer + bash tools|`@inth/docs/search/tanstack`| -|Cloudflare AI Gateway / Workers AI answer + bash tools|`@inth/docs/search/cloudflare`| - -## Build-Time Indexing - -Generate the index after converting MDX to markdown: - -```ts -await generateDocsSearchFiles({ - outDir: "public", - baseUrl: "https://docs.example.com", -}); -``` - -The generator reads markdown under `{outDir}/docs` and writes split files by -default: - -```txt -{outDir}/docs/search-index.json -{outDir}/docs/search-content.json -``` - -`search-index.json` uses a compact v2 tuple format for documents, chunks, and -term postings. `search-content.json` stores answer-source text separately. This -keeps search metadata smaller and gives the AI path a precise way to read only -the page or heading chunks it needs. - -## Runtime Search - -The core runtime is edge-safe. Import both generated JSON files and pass content -when you want excerpts: - -```ts -const results = searchDocs(indexJson as DocsSearchIndex, "tabs install", { - content: contentJson as DocsSearchContentStore, -}); -``` - -Search uses normalized tokens, a small stopword list, heading-aware chunks, and -BM25-style ranking. Titles and headings are weighted above body text; code is -searchable with a lower weight. - -## Docs Content Files - -The same generated index also acts as a small virtual docs filesystem: - -```ts -const files = listDocsContentFiles(indexJson as DocsSearchIndex); -const quickstart = readDocsContentFile( - indexJson as DocsSearchIndex, - "guides/quickstart", - contentJson as DocsSearchContentStore -); -const sourceChunk = readDocsContentChunk( - indexJson as DocsSearchIndex, - "chunk-0", - contentJson as DocsSearchContentStore -); -``` - -Use `readDocsContentFile` when a UI or agent wants a whole normalized page. Use -`readDocsContentChunk` when the search result already identifies the relevant -heading chunk. Returned chunks include heading paths, hash URLs, source text, and -metadata needed for citations. - -## Answer Context - -Use `createAnswerContext` when wiring a custom model call: - -```ts -const context = createAnswerContext(indexJson as DocsSearchIndex, query, { - content: contentJson as DocsSearchContentStore, - productName: "My Docs", -}); -``` - -The returned `system` and `prompt` instruct the model to answer only from -retrieved docs context, cite sources with `[1]` style citations, treat docs text -as untrusted reference content, and say when context is insufficient. - -## Vercel AI Gateway / AI SDK Streaming - -Use `streamDocsAnswer` for a minimal Vercel AI SDK integration: - -```ts -import { streamDocsAnswer } from "@inth/docs/search/vercel"; - -const { response, sources } = streamDocsAnswer({ - index: indexJson as DocsSearchIndex, - content: contentJson as DocsSearchContentStore, - query, - model: process.env.DOCS_SEARCH_MODEL ?? "openai/gpt-5.4-mini", - productName: "My Docs", -}); -``` - -`streamDocsAnswer` returns a plain text `Response`. Display `sources` -separately in your own UI; they are metadata for source links, not embedded in -the streamed answer. - -## BYO Gateway Streaming - -TanStack callers pass the adapter explicitly: - -```ts -import { streamDocsAnswer } from "@inth/docs/search/tanstack"; - -const { response, sources } = streamDocsAnswer({ - index: indexJson as DocsSearchIndex, - content: contentJson as DocsSearchContentStore, - query, - adapter, -}); -``` - -Cloudflare callers use explicit provider mapping: - -```ts -import { - createCloudflareDocsAdapter, - streamDocsAnswer, -} from "@inth/docs/search/cloudflare"; - -const adapter = createCloudflareDocsAdapter({ - provider: "openai", - model: "gpt-4o", - options: { - binding: env.AI.gateway("docs-gateway"), - }, -}); - -const { response, sources } = streamDocsAnswer({ - index: indexJson as DocsSearchIndex, - content: contentJson as DocsSearchContentStore, - query, - adapter, -}); -``` - -## Bash Tool Adapters - -Use the matching provider entrypoint when an agent should inspect docs through -shell commands instead of receiving only preselected chunks: - -```ts -import { createDocsBashTool } from "@inth/docs/search/vercel"; - -const { tools, instructions } = await createDocsBashTool( - indexJson as DocsSearchIndex, - contentJson as DocsSearchContentStore -); -``` - -Vercel wraps the read-only `/docs` filesystem with `bash-tool`. TanStack and -Cloudflare export `createDocsBashTools`, which exposes native TanStack tools over -the same filesystem. Network commands, Python, JavaScript execution, and -filesystem writes are disabled by default. - -## Abuse Guards - -The package includes reusable request-path utilities: - -* `validateDocsQuery` trims and caps query text. -* `readJsonWithLimit` rejects oversized JSON bodies before parsing. -* `getClientIdentifier` reads common proxy IP headers. -* `createMemoryRateLimiter` implements the `RateLimiter` interface for demos. - -In-memory rate limiting is not strong across serverless instances. Production -docs sites should adapt the `RateLimiter` interface to a shared store such as -Redis, Vercel KV, Cloudflare KV, or Durable Objects. - -## When To Use Embeddings - -Start with the local index for most docs sites. It is static, cheap, portable to -Vercel and Cloudflare, and has no request-time database dependency. As docs grow, -keep lexical search for exact APIs, config keys, paths, and errors; add a -virtual content layer for precise page reads; then add embeddings or hosted -search when users need semantic matches that do not share vocabulary with the -docs, or when chunk counts become large enough to hurt cold-start memory. diff --git a/packages/docs/agent-docs/llms-full.txt b/packages/docs/agent-docs/llms-full.txt deleted file mode 100644 index a932bb3..0000000 --- a/packages/docs/agent-docs/llms-full.txt +++ /dev/null @@ -1,9 +0,0 @@ -# @inth/docs Full Context Router - -> Start with the product summary, then the curated docs summary, then one topic-specific full-context file if needed. - -## Recommended Flow - -- [Product Summary](https://example.invalid/@inth/docs/llms.txt): Short product-oriented overview of @inth/docs. -- [Documentation Summary](https://example.invalid/@inth/docs/docs/llms.txt): Curated docs map for implementation work. -- [Documentation Full Router](https://example.invalid/@inth/docs/docs/llms-full.txt): Topic-specific deep-context files. \ No newline at end of file diff --git a/packages/docs/agent-docs/llms.txt b/packages/docs/agent-docs/llms.txt deleted file mode 100644 index 1662b80..0000000 --- a/packages/docs/agent-docs/llms.txt +++ /dev/null @@ -1,21 +0,0 @@ -# @inth/docs - -> Shared MDX conversion, linting, and LLM-doc generation package. - -## Product Summary - -- Flattens MDX-heavy docs into clean markdown for agents. -- Generates llms.txt plus topic-scoped full-context bundles. -- Builds compact static search indexes and source-grounded answer prompts. -- Validates frontmatter, docs metadata, and internal docs links. - -## Best Starting Points - -- [@inth/docs](https://example.invalid/@inth/docs/docs): Reference map for the shared MDX conversion, linting, and LLM doc-generation package. -- [Convert](https://example.invalid/@inth/docs/docs/convert): How to convert MDX docs into Markdown with @inth/docs/convert. -- [LLM](https://example.invalid/@inth/docs/docs/llm): How to generate llms.txt and topic-scoped full-context files from @inth/docs. -- [Search](https://example.invalid/@inth/docs/docs/search): Generate and query a static docs search index, then stream source-grounded AI answers. - -## Agent Guidance - -Start with /docs/llms.txt to route the task, then open the smallest matching topic page. \ No newline at end of file diff --git a/packages/docs/package.json b/packages/docs/package.json index 5f6dbc2..0a8490f 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -9,6 +9,10 @@ }, "sideEffects": false, "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, "./remark": { "types": "./dist/remark/index.d.ts", "import": "./dist/remark/index.js" @@ -54,20 +58,17 @@ "import": "./dist/lint/index.js" } }, - "bin": { - "inth-docs-lint": "./dist/lint/cli.js" - }, + "bin": "./dist/cli.js", "files": [ "dist", - "agent-docs", + "docs", "README.md" ], "scripts": { - "build": "bun run docs:agent && tsup", + "build": "tsup && bun run docs:generate", "dev": "tsup --watch", "check-types": "tsc --noEmit", - "docs:agent": "bun run docs:agent:generate", - "docs:agent:generate": "bun run ./scripts/generate-agent-docs.ts", + "docs:generate": "bun run ./scripts/generate-docs.ts", "lint": "ultracite check src", "test": "vitest run" }, diff --git a/packages/docs/scripts/generate-agent-docs.ts b/packages/docs/scripts/generate-agent-docs.ts deleted file mode 100644 index ed1f467..0000000 --- a/packages/docs/scripts/generate-agent-docs.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { rm } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { convertAllMdx } from "../src/convert/index"; -import { generateLLMFullContextFiles, generateLlmsTxt } from "../src/llm/index"; -import { defaultRemarkPlugins } from "../src/remark/index"; - -const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); -const SRC_DIR = join(PACKAGE_ROOT, "agent-docs-src"); -const OUT_DIR = join(PACKAGE_ROOT, "agent-docs"); -const fallbackBaseUrl = "https://example.invalid/@inth/docs"; -const configuredBaseUrl = process.env.INTH_DOCS_AGENT_BASE_URL?.trim(); -const baseUrl = configuredBaseUrl || fallbackBaseUrl; - -if (!configuredBaseUrl) { - process.stderr.write( - `INTH_DOCS_AGENT_BASE_URL not set; using ${fallbackBaseUrl} for generated package docs.\n` - ); -} - -await rm(OUT_DIR, { recursive: true, force: true }); - -await convertAllMdx({ - srcDir: SRC_DIR, - outDir: OUT_DIR, - remarkPlugins: defaultRemarkPlugins, -}); - -await generateLlmsTxt({ - srcDir: SRC_DIR, - outDir: OUT_DIR, - baseUrl, - product: { - name: "@inth/docs", - summary: "Shared MDX conversion, linting, and LLM-doc generation package.", - bullets: [ - "Flattens MDX-heavy docs into clean markdown for agents.", - "Generates llms.txt plus topic-scoped full-context bundles.", - "Builds compact static search indexes and source-grounded answer prompts.", - "Validates frontmatter, docs metadata, and internal docs links.", - ], - bestStartingPoints: [ - { urlPath: "/docs" }, - { urlPath: "/docs/convert" }, - { urlPath: "/docs/llm" }, - { urlPath: "/docs/search" }, - ], - agentGuidance: - "Start with /docs/llms.txt to route the task, then open the smallest matching topic page.", - }, - docsSections: [ - { - title: "Overview", - description: "Start here for package scope and surface selection.", - links: [{ urlPath: "/docs" }], - }, - { - title: "Authoring And Rendering", - description: "React MDX components and remark pipeline behavior.", - links: [{ urlPath: "/docs/components" }, { urlPath: "/docs/remark" }], - }, - { - title: "Generation", - description: "MDX conversion, LLM output generation, and search.", - links: [ - { urlPath: "/docs/convert" }, - { urlPath: "/docs/llm" }, - { urlPath: "/docs/search" }, - ], - }, - { - title: "Validation", - description: "Content validation and link checks.", - links: [{ urlPath: "/docs/lint" }], - }, - ], -}); - -await generateLLMFullContextFiles({ - outDir: OUT_DIR, - baseUrl, - product: { name: "@inth/docs" }, - topics: [ - { - slug: "overview", - title: "Overview", - description: "Package scope and route-selection guidance.", - includePrefixes: ["index"], - }, - { - slug: "authoring", - title: "Authoring", - description: "MDX rendering components and remark pipeline details.", - topics: [ - { - slug: "components", - title: "Components", - description: "React MDX component adapters.", - includePrefixes: ["components"], - }, - { - slug: "remark", - title: "Remark", - description: "Default plugins and conversion helpers.", - includePrefixes: ["remark"], - }, - ], - }, - { - slug: "generation", - title: "Generation", - description: "MDX conversion and llms.txt generation.", - topics: [ - { - slug: "convert", - title: "Convert", - description: "MDX-to-markdown conversion APIs.", - includePrefixes: ["convert"], - }, - { - slug: "llm", - title: "LLM", - description: "Summary and full-context file generation.", - includePrefixes: ["llm"], - }, - { - slug: "search", - title: "Search", - description: "Static search indexes and AI answer helpers.", - includePrefixes: ["search"], - }, - ], - }, - { - slug: "validation", - title: "Validation", - description: "Docs linting and CLI usage.", - includePrefixes: ["lint"], - }, - ], -}); - -process.stdout.write(`Generated agent docs in ${OUT_DIR}\n`); diff --git a/packages/docs/scripts/generate-docs.ts b/packages/docs/scripts/generate-docs.ts new file mode 100644 index 0000000..de4f486 --- /dev/null +++ b/packages/docs/scripts/generate-docs.ts @@ -0,0 +1,80 @@ +import { rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import docsConfig from "../../../docs/docs.config"; +import { convertAllMdx } from "../src/convert/index"; +import { + generateLLMFullContextFiles, + generateLlmsTxt, + resolveDocsNavigation, +} from "../src/llm/index"; +import { defaultRemarkPlugins } from "../src/remark/index"; + +const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const REPO_ROOT = resolve(PACKAGE_ROOT, "..", ".."); +const SRC_DOCS_DIR = join(REPO_ROOT, "docs"); +const OUT_DOCS_DIR = join(PACKAGE_ROOT, "docs"); + +const fallbackBaseUrl = "https://example.invalid/@inth/docs"; +const configuredBaseUrl = process.env.INTH_DOCS_AGENT_BASE_URL?.trim(); +const baseUrl = configuredBaseUrl || fallbackBaseUrl; + +if (!configuredBaseUrl) { + process.stderr.write( + `INTH_DOCS_AGENT_BASE_URL not set; using ${fallbackBaseUrl} for generated package docs.\n` + ); +} + +// The output folder is entirely generated and gitignored — safe to nuke. +// This also clears the root-level `llms.txt` / `llms-full.txt` byproducts +// emitted by the llm helper at PACKAGE_ROOT. +await rm(OUT_DOCS_DIR, { recursive: true, force: true }); +await rm(join(PACKAGE_ROOT, "llms.txt"), { force: true }); +await rm(join(PACKAGE_ROOT, "llms-full.txt"), { force: true }); + +await convertAllMdx({ + srcDir: SRC_DOCS_DIR, + outDir: OUT_DOCS_DIR, + remarkPlugins: defaultRemarkPlugins, +}); + +// `generateLlmsTxt` and `generateLLMFullContextFiles` join `${dir}/docs/` +// internally, so we pass the parents (REPO_ROOT, PACKAGE_ROOT) — they then +// read source MDX from `/docs/` and write outputs to `/docs/`. +await generateLlmsTxt({ + srcDir: REPO_ROOT, + outDir: PACKAGE_ROOT, + baseUrl, + product: docsConfig.product, + groups: docsConfig.groups, +}); + +await generateLLMFullContextFiles({ + outDir: PACKAGE_ROOT, + baseUrl, + product: { name: docsConfig.product.name }, + groups: docsConfig.groups, +}); + +// Surface unknown-group references early — the lint rule catches typos in +// CI, but the build script also fails fast so a bad config can't ship. +const navigation = await resolveDocsNavigation({ + srcDir: REPO_ROOT, + baseUrl, + groups: docsConfig.groups, +}); +if (navigation.unknown.length > 0) { + for (const { urlPath, slug } of navigation.unknown) { + process.stderr.write( + `error: ${urlPath} declares unknown group "${slug}".\n` + ); + } + process.exit(1); +} + +await writeFile( + join(OUT_DOCS_DIR, "navigation.json"), + `${JSON.stringify(navigation, null, 2)}\n` +); + +process.stdout.write(`Generated docs in ${OUT_DOCS_DIR}\n`); diff --git a/packages/docs/src/cli.test.ts b/packages/docs/src/cli.test.ts new file mode 100644 index 0000000..6c0ceac --- /dev/null +++ b/packages/docs/src/cli.test.ts @@ -0,0 +1,276 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { runCli } from "./cli"; + +const tempDirs: string[] = []; +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../.." +); + +type Capture = { + stderr: string; + stdout: string; + io: { + stderr: { write: (chunk: string) => boolean }; + stdout: { write: (chunk: string) => boolean }; + }; +}; + +function createCapture(): Capture { + const capture = { + stderr: "", + stdout: "", + }; + return { + ...capture, + io: { + stderr: { + write: (chunk: string) => { + capture.stderr += chunk; + return true; + }, + }, + stdout: { + write: (chunk: string) => { + capture.stdout += chunk; + return true; + }, + }, + }, + get stderr() { + return capture.stderr; + }, + get stdout() { + return capture.stdout; + }, + }; +} + +async function createTempDir(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "inth-docs-cli-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map(async (dir) => { + await rm(dir, { force: true, recursive: true }); + }) + ); +}); + +describe("@inth/docs CLI", () => { + it("prints the command list", async () => { + const capture = createCapture(); + + const code = await runCli(["--help"], capture.io); + + expect(code).toBe(0); + expect(capture.stdout).toContain("@inth/docs "); + expect(capture.stdout).toContain("generate"); + expect(capture.stdout).toContain("lint"); + }); + + it("runs lint against this repo's docs", async () => { + const capture = createCapture(); + + const code = await runCli( + ["lint", path.join(repoRoot, "docs")], + capture.io + ); + + expect(code).toBe(0); + expect(capture.stderr).toContain("files pass."); + }); + + it("generates markdown, LLM files, and search files from this repo's docs", async () => { + const outDir = await createTempDir(); + const capture = createCapture(); + + const code = await runCli( + [ + "generate", + "--src", + repoRoot, + "--out", + outDir, + "--base-url", + "https://docs.example.com/@inth/docs", + "--name", + "@inth/docs", + "--summary", + "Shared MDX conversion, linting, and LLM-doc generation package.", + ], + capture.io + ); + + expect(code).toBe(0); + expect(capture.stdout).toContain("Generated docs pipeline output"); + expect(existsSync(path.join(outDir, "docs", "methodology.md"))).toBe(true); + expect( + existsSync(path.join(outDir, "docs", "guides", "connect-docs-site.md")) + ).toBe(true); + expect(existsSync(path.join(outDir, "llms.txt"))).toBe(true); + expect(existsSync(path.join(outDir, "docs", "llms.txt"))).toBe(true); + expect(existsSync(path.join(outDir, "docs", "llms-full.txt"))).toBe(true); + expect(existsSync(path.join(outDir, "docs", "search-index.json"))).toBe( + true + ); + expect(existsSync(path.join(outDir, "docs", "search-content.json"))).toBe( + true + ); + + const docsSummary = await readFile( + path.join(outDir, "docs", "llms.txt"), + "utf8" + ); + expect(docsSummary).toContain("Methodology"); + expect(docsSummary).toContain("Connect a docs site"); + }); + + it("prints machine-readable generate output for agents", async () => { + const outDir = await createTempDir(); + const capture = createCapture(); + + const code = await runCli( + [ + "generate", + "--src", + repoRoot, + "--out", + outDir, + "--base-url", + "https://docs.example.com/@inth/docs", + "--name", + "@inth/docs", + "--summary", + "Shared MDX conversion, linting, and LLM-doc generation package.", + "--format", + "json", + ], + capture.io + ); + + expect(code).toBe(0); + const result = JSON.parse(capture.stdout) as { + files: { searchIndex: string }; + groups: Array<{ slug: string }>; + outDir: string; + search: { docs: number }; + }; + expect(result.outDir).toBe(outDir); + expect(result.files.searchIndex).toBe( + path.join(outDir, "docs", "search-index.json") + ); + expect(result.groups.map((group) => group.slug)).toContain("guides"); + expect(result.search.docs).toBeGreaterThan(0); + }); + + it("filters generated docs by include path globs", async () => { + const outDir = await createTempDir(); + const capture = createCapture(); + + const code = await runCli( + [ + "generate", + "--src", + repoRoot, + "--out", + outDir, + "--include", + "guides/**", + "--format", + "json", + ], + capture.io + ); + + expect(code).toBe(0); + const result = JSON.parse(capture.stdout) as { + filters: { include: string[] }; + }; + expect(result.filters.include).toEqual(["guides/**"]); + expect( + existsSync(path.join(outDir, "docs", "guides", "connect-docs-site.md")) + ).toBe(true); + expect( + existsSync(path.join(outDir, "docs", "guides", "bundle-package-docs.md")) + ).toBe(true); + expect(existsSync(path.join(outDir, "docs", "methodology.md"))).toBe(false); + }); + + it("applies exclude path globs after includes", async () => { + const outDir = await createTempDir(); + const capture = createCapture(); + + const code = await runCli( + [ + "generate", + "--src", + repoRoot, + "--out", + outDir, + "--include", + "guides/**", + "--exclude", + "guides/connect-docs-site.mdx", + ], + capture.io + ); + + expect(code).toBe(0); + expect( + existsSync(path.join(outDir, "docs", "guides", "bundle-package-docs.md")) + ).toBe(true); + expect( + existsSync(path.join(outDir, "docs", "guides", "connect-docs-site.md")) + ).toBe(false); + }); + + it("returns structured JSON when filters match no MDX files", async () => { + const outDir = await createTempDir(); + const capture = createCapture(); + + const code = await runCli( + [ + "generate", + "--src", + repoRoot, + "--out", + outDir, + "--include", + "nope/**", + "--format", + "json", + ], + capture.io + ); + + expect(code).toBe(1); + const error = JSON.parse(capture.stderr) as { + error: string; + filters: { include: string[] }; + }; + expect(error.error).toContain("No MDX files matched"); + expect(error.filters.include).toEqual(["nope/**"]); + }); + + it("fails clearly when the docs source directory is missing", async () => { + const tempDir = await createTempDir(); + const capture = createCapture(); + + const code = await runCli( + ["generate", "--src", tempDir, "--docs-dir", "missing"], + capture.io + ); + + expect(code).toBe(1); + expect(capture.stderr).toContain("docs directory not found"); + }); +}); diff --git a/packages/docs/src/cli.ts b/packages/docs/src/cli.ts new file mode 100644 index 0000000..fcdda1b --- /dev/null +++ b/packages/docs/src/cli.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { getGenerateUsage, runGenerateCommand } from "./cli/generate"; +import { getLintUsage, runLintCommand } from "./lint/cli"; + +type CliIo = { + stderr: Pick; + stdout: Pick; +}; + +const MAIN_USAGE = `@inth/docs — docs pipeline tooling + +Usage: + @inth/docs [options] + +Commands: + generate Convert MDX, generate LLM files, and build search artifacts + lint Validate MDX frontmatter, meta.json, and docs links + help Show help + +Run @inth/docs --help for command-specific options. +`; + +function commandUsage(command: string | undefined): string { + if (command === "generate") { + return getGenerateUsage(); + } + if (command === "lint") { + return getLintUsage(); + } + return MAIN_USAGE; +} + +export async function runCli( + argv: string[], + io: CliIo = { stderr: process.stderr, stdout: process.stdout } +): Promise { + const [command, ...rest] = argv; + + if (!command || command === "-h" || command === "--help") { + io.stdout.write(MAIN_USAGE); + return 0; + } + + if (command === "help") { + io.stdout.write(commandUsage(rest[0])); + return 0; + } + + if (command === "generate") { + return await runGenerateCommand(rest, io); + } + + if (command === "lint") { + return await runLintCommand(rest, io); + } + + io.stderr.write(`unknown command: ${command}\n\n${MAIN_USAGE}`); + return 2; +} + +function isDirectRun(): boolean { + const entry = process.argv[1]; + return entry ? import.meta.url === pathToFileURL(resolve(entry)).href : false; +} + +if (isDirectRun()) { + runCli(process.argv.slice(2)) + .then((code) => { + process.exit(code); + }) + .catch((error) => { + process.stderr.write(`@inth/docs: ${String(error)}\n`); + process.exit(1); + }); +} diff --git a/packages/docs/src/cli/generate.ts b/packages/docs/src/cli/generate.ts new file mode 100644 index 0000000..62fe8ef --- /dev/null +++ b/packages/docs/src/cli/generate.ts @@ -0,0 +1,424 @@ +import { existsSync } from "node:fs"; +import { cp, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import fg from "fast-glob"; +import matter from "gray-matter"; +import { convertAllMdx } from "../convert"; +import type { DocsGroup, ProductInfo } from "../llm"; +import { generateLLMFullContextFiles, generateLlmsTxt } from "../llm"; +import { defaultRemarkPlugins } from "../remark"; +import type { GenerateDocsSearchFilesResult } from "../search/node"; +import { generateDocsSearchFiles } from "../search/node"; + +const DEFAULT_DOCS_DIR = "docs"; +const DEFAULT_OUT_DIR = "public"; +const GROUP_SEPARATOR_PATTERN = /[-_]+/g; +const TITLE_CASE_PATTERN = /\b\w/g; +const FORMAT_VALUES = new Set(["text", "json"]); + +type GenerateFormat = "json" | "text"; + +export type GenerateArgs = { + baseUrl?: string; + docsDir: string; + enrichGit: boolean; + exclude: string[]; + format: GenerateFormat; + help: boolean; + include: string[]; + name?: string; + outDir: string; + srcDir: string; + summary?: string; +}; + +export type GenerateIo = { + stderr: Pick; + stdout: Pick; +}; + +type SourceMirror = { + cleanup: () => Promise; + docsDir: string; + filters: GenerateFilters; + srcDir: string; +}; + +type GenerateFilters = { + exclude: string[]; + include: string[]; +}; + +type GenerateResult = { + docsDir: string; + files: { + docsLlmsFullTxt: string; + docsLlmsTxt: string; + llmsTxt: string; + searchContent?: string; + searchIndex: string; + }; + groups: DocsGroup[]; + filters: GenerateFilters; + outDir: string; + product: ProductInfo; + search: GenerateDocsSearchFilesResult; + srcDir: string; +}; + +const GENERATE_USAGE = `@inth/docs generate — convert MDX, generate LLM files, and build search artifacts + +Usage: + @inth/docs generate [options] + +Options: + --src Source repo/root directory (default: .) + --docs-dir Docs folder relative to --src (default: docs) + --out Output root directory (default: public) + --base-url Base URL for generated links + --name Product name for generated LLM files + --summary Product summary for generated LLM files + --include Include MDX paths matching this docs-root-relative glob + --exclude Exclude MDX paths matching this docs-root-relative glob + --enrich-git Add lastModified and lastAuthor from git history + --format text | json (default: text) + --json Alias for --format json + -h, --help Show this help +`; + +function readValue(argv: string[], index: number, flag: string): string { + const value = argv[index]; + if (!value || value.startsWith("-")) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +export function parseGenerateArgs(argv: string[]): GenerateArgs { + const args: GenerateArgs = { + docsDir: DEFAULT_DOCS_DIR, + enrichGit: false, + exclude: [], + format: "text", + help: false, + include: [], + outDir: DEFAULT_OUT_DIR, + srcDir: ".", + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "-h" || arg === "--help") { + args.help = true; + } else if (arg === "--src") { + args.srcDir = readValue(argv, ++i, "--src"); + } else if (arg === "--docs-dir") { + args.docsDir = readValue(argv, ++i, "--docs-dir"); + } else if (arg === "--out") { + args.outDir = readValue(argv, ++i, "--out"); + } else if (arg === "--base-url") { + args.baseUrl = readValue(argv, ++i, "--base-url"); + } else if (arg === "--name") { + args.name = readValue(argv, ++i, "--name"); + } else if (arg === "--summary") { + args.summary = readValue(argv, ++i, "--summary"); + } else if (arg === "--include") { + args.include.push(readValue(argv, ++i, "--include")); + } else if (arg === "--exclude") { + args.exclude.push(readValue(argv, ++i, "--exclude")); + } else if (arg === "--enrich-git") { + args.enrichGit = true; + } else if (arg === "--format") { + const value = readValue(argv, ++i, "--format"); + if (!FORMAT_VALUES.has(value)) { + throw new Error(`--format must be text|json, got ${value}`); + } + args.format = value as GenerateFormat; + } else if (arg === "--json") { + args.format = "json"; + } else if (arg) { + throw new Error(`unknown option: ${arg}`); + } + } + + return args; +} + +function titleizeGroup(slug: string): string { + return slug + .replace(GROUP_SEPARATOR_PATTERN, " ") + .replace(TITLE_CASE_PATTERN, (match) => match.toUpperCase()); +} + +function normalizeGroupValues(value: unknown): string[] { + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === "string"); + } + return []; +} + +async function inferGroups(docsDir: string): Promise { + const files = await fg("**/*.mdx", { + absolute: true, + cwd: docsDir, + onlyFiles: true, + }); + const slugs = new Set(); + + for (const file of files) { + const raw = await readFile(file, "utf8"); + const parsed = matter(raw); + for (const slug of normalizeGroupValues(parsed.data.group)) { + const trimmed = slug.trim(); + if (trimmed.length > 0) { + slugs.add(trimmed); + } + } + } + + return Array.from(slugs) + .sort((left, right) => left.localeCompare(right)) + .map((slug) => ({ + slug, + title: titleizeGroup(slug), + })); +} + +async function readPackageProduct( + srcDir: string, + args: GenerateArgs +): Promise { + if (args.name && args.summary) { + return { + name: args.name, + summary: args.summary, + }; + } + + const packageJsonPath = path.join(srcDir, "package.json"); + let packageData: Record = {}; + if (existsSync(packageJsonPath)) { + packageData = JSON.parse(await readFile(packageJsonPath, "utf8")) as Record< + string, + unknown + >; + } + + const name = + args.name ?? (typeof packageData.name === "string" ? packageData.name : ""); + const summary = + args.summary ?? + (typeof packageData.description === "string" + ? packageData.description + : ""); + + return { + name: name || "Docs", + summary: summary || "Generated documentation.", + }; +} + +async function createSourceMirror( + srcDir: string, + docsDir: string, + args: GenerateArgs +): Promise { + const filters = { + exclude: [...args.exclude], + include: [...args.include], + }; + const hasFilters = filters.include.length > 0 || filters.exclude.length > 0; + + if (path.normalize(args.docsDir) === DEFAULT_DOCS_DIR && !hasFilters) { + return { + cleanup: async () => { + return; + }, + docsDir, + filters, + srcDir, + }; + } + + const tempRoot = await mkdtemp(path.join(tmpdir(), "inth-docs-generate-")); + const tempDocsDir = path.join(tempRoot, DEFAULT_DOCS_DIR); + + if (hasFilters) { + const patterns = + filters.include.length > 0 ? filters.include : ["**/*.mdx"]; + const files = await fg(patterns, { + absolute: false, + cwd: docsDir, + ignore: filters.exclude, + onlyFiles: true, + }); + const mdxFiles = files + .filter((file) => file.endsWith(".mdx")) + .sort((left, right) => left.localeCompare(right)); + + if (mdxFiles.length === 0) { + await rm(tempRoot, { force: true, recursive: true }); + throw new Error( + "No MDX files matched the provided include/exclude filters" + ); + } + + await Promise.all( + mdxFiles.map(async (file) => { + const sourcePath = path.join(docsDir, file); + const targetPath = path.join(tempDocsDir, file); + await mkdir(path.dirname(targetPath), { recursive: true }); + await cp(sourcePath, targetPath); + }) + ); + } else { + await cp(docsDir, tempDocsDir, { + recursive: true, + }); + } + + return { + cleanup: async () => { + await rm(tempRoot, { force: true, recursive: true }); + }, + docsDir: tempDocsDir, + filters, + srcDir: tempRoot, + }; +} + +export function getGenerateUsage(): string { + return GENERATE_USAGE; +} + +function renderGenerateResult(result: GenerateResult): string { + return JSON.stringify(result, null, 2); +} + +export async function runGenerateCommand( + argv: string[], + io: GenerateIo = { stderr: process.stderr, stdout: process.stdout } +): Promise { + let args: GenerateArgs; + try { + args = parseGenerateArgs(argv); + } catch (error) { + io.stderr.write(`${String(error)}\n\n${GENERATE_USAGE}`); + return 2; + } + + if (args.help) { + io.stdout.write(GENERATE_USAGE); + return 0; + } + + const srcDir = path.resolve(args.srcDir); + const docsDir = path.resolve(srcDir, args.docsDir); + const outDir = path.resolve(args.outDir); + + if (!existsSync(docsDir)) { + if (args.format === "json") { + io.stderr.write( + `${JSON.stringify( + { + error: "docs directory not found", + path: docsDir, + }, + null, + 2 + )}\n` + ); + } else { + io.stderr.write( + `@inth/docs generate: docs directory not found at ${docsDir}\n` + ); + } + return 1; + } + + const product = await readPackageProduct(srcDir, args); + let sourceMirror: SourceMirror; + try { + sourceMirror = await createSourceMirror(srcDir, docsDir, args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (args.format === "json") { + io.stderr.write( + `${JSON.stringify( + { + error: message, + filters: { + exclude: args.exclude, + include: args.include, + }, + }, + null, + 2 + )}\n` + ); + } else { + io.stderr.write(`@inth/docs generate: ${message}\n`); + } + return 1; + } + const groups = await inferGroups(sourceMirror.docsDir); + + try { + await convertAllMdx({ + srcDir: sourceMirror.docsDir, + outDir: path.join(outDir, "docs"), + remarkPlugins: defaultRemarkPlugins, + enrichFrontmatterFromGit: args.enrichGit, + }); + + await generateLlmsTxt({ + srcDir: sourceMirror.srcDir, + outDir, + baseUrl: args.baseUrl, + product, + groups, + }); + + await generateLLMFullContextFiles({ + outDir, + baseUrl: args.baseUrl, + product: { name: product.name }, + groups, + }); + + const search = await generateDocsSearchFiles({ + outDir, + baseUrl: args.baseUrl, + }); + + const result: GenerateResult = { + docsDir, + files: { + docsLlmsFullTxt: path.join(outDir, "docs", "llms-full.txt"), + docsLlmsTxt: path.join(outDir, "docs", "llms.txt"), + llmsTxt: path.join(outDir, "llms.txt"), + searchContent: search.contentOutputPath, + searchIndex: search.outputPath, + }, + filters: sourceMirror.filters, + groups, + outDir, + product, + search, + srcDir, + }; + + if (args.format === "json") { + io.stdout.write(`${renderGenerateResult(result)}\n`); + } else { + io.stdout.write(`Generated docs pipeline output in ${outDir}\n`); + } + } finally { + await sourceMirror.cleanup(); + } + return 0; +} diff --git a/packages/docs/src/index.ts b/packages/docs/src/index.ts new file mode 100644 index 0000000..f6396e3 --- /dev/null +++ b/packages/docs/src/index.ts @@ -0,0 +1,10 @@ +// Root entry for `@inth/docs`. Exposes config helpers used by docs sites, +// agent tooling, and the LLM-bundle pipeline. Specialized surfaces stay on +// dedicated subpaths (`@inth/docs/convert`, `/llm`, `/search`, `/lint`). +export { + type CuratedLink, + type DocsConfig, + type DocsGroup, + defineDocsConfig, + type ProductInfo, +} from "./llm"; diff --git a/packages/docs/src/internal/package-surface.test.ts b/packages/docs/src/internal/package-surface.test.ts index 80997dc..8b42ee5 100644 --- a/packages/docs/src/internal/package-surface.test.ts +++ b/packages/docs/src/internal/package-surface.test.ts @@ -4,8 +4,9 @@ import packageJson from "../../package.json"; const exportedPaths = Object.keys(packageJson.exports); describe("package surface", () => { - it("does not expose runtime component entry points", () => { + it("matches the documented entry-point list", () => { const expectedExportedPaths = [ + ".", "./remark", "./convert", "./llm", @@ -23,8 +24,9 @@ describe("package surface", () => { expect(new Set(exportedPaths)).toEqual(new Set(expectedExportedPaths)); }); - it("does not expose root or runtime component adapters", () => { - expect(exportedPaths).not.toContain("."); + it("does not expose framework-specific runtime component adapters", () => { expect(exportedPaths).not.toContain("./react"); + expect(exportedPaths).not.toContain("./vue"); + expect(exportedPaths).not.toContain("./svelte"); }); }); diff --git a/packages/docs/src/lint/cli.ts b/packages/docs/src/lint/cli.ts index ae09ec5..7bf67fe 100644 --- a/packages/docs/src/lint/cli.ts +++ b/packages/docs/src/lint/cli.ts @@ -4,6 +4,7 @@ import { type ReporterFormat, renderReport } from "./reporters"; import { DEFAULT_IGNORE_GLOBS, type LintSeverity, lintDocs } from "./runner"; const DEFAULT_IGNORE_GLOBS_TEXT = DEFAULT_IGNORE_GLOBS.join(", "); +const STDOUT_FORMATS = new Set(["github", "json"]); type CliArgs = { srcDir: string; @@ -15,10 +16,15 @@ type CliArgs = { help: boolean; }; -const USAGE = `inth-docs-lint — validate MDX frontmatter and meta.json against a schema +export type LintCliIo = { + stderr: Pick; + stdout: Pick; +}; + +const USAGE = `@inth/docs lint — validate MDX frontmatter and meta.json against a schema Usage: - inth-docs-lint [srcDir] [options] + @inth/docs lint [srcDir] [options] Options: --src Source directory (default: ./content) @@ -36,7 +42,11 @@ Exit codes: 2 CLI usage error `; -function parseArgs(argv: string[]): CliArgs { +export function getLintUsage(): string { + return USAGE; +} + +export function parseLintArgs(argv: string[]): CliArgs { const args: CliArgs = { srcDir: "content", format: "pretty", @@ -101,18 +111,21 @@ function parseArgs(argv: string[]): CliArgs { return args; } -async function main(): Promise { +export async function runLintCommand( + argv: string[], + io: LintCliIo = { stderr: process.stderr, stdout: process.stdout } +): Promise { let args: CliArgs; try { - args = parseArgs(process.argv.slice(2)); + args = parseLintArgs(argv); } catch (error) { - process.stderr.write(`${String(error)}\n\n${USAGE}`); - process.exit(2); + io.stderr.write(`${String(error)}\n\n${USAGE}`); + return 2; } if (args.help) { - process.stdout.write(USAGE); - return; + io.stdout.write(USAGE); + return 0; } const resolvedSrcDir = resolve(args.srcDir); @@ -130,18 +143,12 @@ async function main(): Promise { const output = renderReport(args.format, result); // Machine-readable formats go to stdout so they can be piped; the pretty // format goes to stderr so stdout stays clean when scripts mix formats. - const STDOUT_FORMATS = new Set(["github", "json"]); if (STDOUT_FORMATS.has(args.format)) { - process.stdout.write(output); + io.stdout.write(output); } else { - process.stderr.write(output); + io.stderr.write(output); } const exceedsWarnings = result.summary.warnings > args.maxWarnings; - process.exit(result.summary.errors > 0 || exceedsWarnings ? 1 : 0); + return result.summary.errors > 0 || exceedsWarnings ? 1 : 0; } - -main().catch((error) => { - process.stderr.write(`docs-lint: ${String(error)}\n`); - process.exit(1); -}); diff --git a/packages/docs/src/lint/schema.ts b/packages/docs/src/lint/schema.ts index 671da0e..6b5c845 100644 --- a/packages/docs/src/lint/schema.ts +++ b/packages/docs/src/lint/schema.ts @@ -53,6 +53,7 @@ export const defaultFrontmatterSchema = v.object({ // Categorization tags: v.optional(v.array(v.string())), + group: v.optional(v.union([v.string(), v.array(v.string())])), availableIn: v.optional(v.array(availableInEntry)), // Layout diff --git a/packages/docs/src/llm/index.ts b/packages/docs/src/llm/index.ts index 830b1fe..c060f9b 100644 --- a/packages/docs/src/llm/index.ts +++ b/packages/docs/src/llm/index.ts @@ -1,12 +1,18 @@ export { type CuratedLink, - type CuratedSection, - type FullTopic, + type DocsConfig, + type DocsGroup, + type DocsNavigation, + type DocsNavigationGroup, + type DocsNavigationPage, + defineDocsConfig, generateLLMFullContextFiles, generateLlmsTxt, type LLMFullContextConfig, type LlmsTxtConfig, type MarkdownDoc, type ProductInfo, + type ResolveDocsNavigationConfig, + resolveDocsNavigation, type SourceDoc, } from "./llm"; diff --git a/packages/docs/src/llm/llm.test.ts b/packages/docs/src/llm/llm.test.ts index 00c5b9a..e57656a 100644 --- a/packages/docs/src/llm/llm.test.ts +++ b/packages/docs/src/llm/llm.test.ts @@ -3,7 +3,11 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { generateLLMFullContextFiles, generateLlmsTxt } from "./llm"; +import { + generateLLMFullContextFiles, + generateLlmsTxt, + resolveDocsNavigation, +} from "./llm"; const tempDirs: string[] = []; @@ -21,20 +25,44 @@ afterEach(async () => { ); }); +type SeedFile = { + /** Relative path under `/docs/`, e.g. "frameworks/react/quickstart.md" */ + relativePath: string; + frontmatter: string; + body?: string; +}; + +async function seedDocs(projectDir: string, files: SeedFile[]): Promise { + const docsDir = path.join(projectDir, "docs"); + await Promise.all( + files.map(async (file) => { + const fullPath = path.join(docsDir, file.relativePath); + await mkdir(path.dirname(fullPath), { recursive: true }); + await writeFile( + fullPath, + `---\n${file.frontmatter}\n---\n${file.body ?? ""}` + ); + }) + ); +} + describe("generateLlmsTxt", () => { - it("falls back to section-friendly titles and descriptions for index routes", async () => { + it("renders curated docs sections from the group tree and frontmatter", async () => { const projectDir = await createTempProject(); - const docsDir = path.join(projectDir, "docs", "frameworks"); const outDir = path.join(projectDir, "out"); - await mkdir(docsDir, { recursive: true }); - await writeFile( - path.join(docsDir, "index.mdx"), - ` - - -` - ); + await seedDocs(projectDir, [ + { + relativePath: "frameworks/react/quickstart.mdx", + frontmatter: + "title: React Quickstart\ndescription: Get started with React.\ngroup: react", + }, + { + relativePath: "frameworks/next/quickstart.mdx", + frontmatter: + "title: Next.js Quickstart\ndescription: Get started with Next.js.\ngroup: next", + }, + ]); await generateLlmsTxt({ srcDir: projectDir, @@ -43,37 +71,48 @@ describe("generateLlmsTxt", () => { product: { name: "c15t", summary: "Consent platform.", - bestStartingPoints: [{ urlPath: "/docs/frameworks" }], + bestStartingPoints: [{ urlPath: "/docs/frameworks/react/quickstart" }], }, - docsSections: [ + groups: [ { + slug: "frameworks", title: "Frameworks", - links: [{ urlPath: "/docs/frameworks" }], + description: "Framework integrations.", + children: [ + { + slug: "react", + title: "React", + description: "React integration.", + }, + { + slug: "next", + title: "Next.js", + description: "Next.js integration.", + }, + ], }, ], }); - const rootSummary = await readFile(path.join(outDir, "llms.txt"), "utf8"); const docsSummary = await readFile( path.join(outDir, "docs", "llms.txt"), "utf8" ); - - expect(rootSummary).toContain( - "[Frameworks](https://c15t.com/docs/frameworks)" - ); - expect(rootSummary).toContain("Entry point for Frameworks documentation."); - expect(rootSummary).not.toContain("[Index]"); - expect(docsSummary).not.toContain("No description provided."); + expect(docsSummary).toContain("## Frameworks"); + expect(docsSummary).toContain("React Quickstart"); + expect(docsSummary).toContain("Next.js Quickstart"); }); - it("uses Documentation for root index files without explicit titles", async () => { + it("renders the product summary even when no groups are declared", async () => { const projectDir = await createTempProject(); - const docsDir = path.join(projectDir, "docs"); const outDir = path.join(projectDir, "out"); - await mkdir(docsDir, { recursive: true }); - await writeFile(path.join(docsDir, "index.mdx"), "# Welcome\n"); + await seedDocs(projectDir, [ + { + relativePath: "index.mdx", + frontmatter: "title: Home\ndescription: Welcome.", + }, + ]); await generateLlmsTxt({ srcDir: projectDir, @@ -84,10 +123,42 @@ describe("generateLlmsTxt", () => { summary: "Consent platform.", bestStartingPoints: [{ urlPath: "/docs" }], }, - docsSections: [ + groups: [], + }); + + const rootSummary = await readFile(path.join(outDir, "llms.txt"), "utf8"); + expect(rootSummary).toContain("# c15t"); + expect(rootSummary).toContain("> Consent platform."); + }); + + it("renders shared pages under every group they declare", async () => { + const projectDir = await createTempProject(); + const outDir = path.join(projectDir, "out"); + + await seedDocs(projectDir, [ + { + relativePath: "rate-limiting.mdx", + frontmatter: + "title: Rate Limiting\ndescription: Shared rate-limit reference.\ngroup:\n - search\n - self-host", + }, + { + relativePath: "search-only.mdx", + frontmatter: + "title: Search Only\ndescription: Search-only page.\ngroup: search", + }, + ]); + + await generateLlmsTxt({ + srcDir: projectDir, + outDir, + baseUrl: "https://c15t.com", + product: { name: "c15t", summary: "Consent platform." }, + groups: [ + { slug: "search", title: "Search", description: "Search APIs." }, { - title: "Overview", - links: [{ urlPath: "/docs" }], + slug: "self-host", + title: "Self-host", + description: "Self-host docs.", }, ], }); @@ -96,92 +167,52 @@ describe("generateLlmsTxt", () => { path.join(outDir, "docs", "llms.txt"), "utf8" ); - - expect(docsSummary).toContain("[Documentation](https://c15t.com/docs)"); - expect(docsSummary).not.toContain("[.](https://c15t.com/docs)"); + const searchSection = docsSummary.split("## Search")[1] ?? ""; + const selfHostSection = docsSummary.split("## Self-host")[1] ?? ""; + expect(searchSection).toContain("Rate Limiting"); + expect(selfHostSection).toContain("Rate Limiting"); + expect(searchSection).toContain("Search Only"); + expect(selfHostSection).not.toContain("Search Only"); }); }); -async function seedOutDir(outDir: string): Promise { - const docsDir = path.join(outDir, "docs"); - await mkdir(path.join(docsDir, "frameworks", "react"), { recursive: true }); - await mkdir(path.join(docsDir, "frameworks", "next"), { recursive: true }); - await mkdir(path.join(docsDir, "self-host", "api"), { recursive: true }); - await mkdir(path.join(docsDir, "self-host", "guides"), { recursive: true }); - - const write = (relative: string, frontmatter: string, body: string) => - writeFile( - path.join(docsDir, relative), - `---\n${frontmatter}\n---\n${body}` - ); - - await write( - "frameworks/react/quickstart.md", - "title: React Quickstart\ndescription: Get started with React.", - "# React Quickstart\n\nBody.\n" - ); - await write( - "frameworks/next/quickstart.md", - "title: Next.js Quickstart\ndescription: Get started with Next.js.", - "# Next.js Quickstart\n\nBody.\n" - ); - await write( - "self-host/api/configuration.md", - "title: Configuration\ndescription: Config reference.", - "# Configuration\n\nBody.\n" - ); - await write( - "self-host/guides/caching.md", - "title: Caching\ndescription: Cache guide.", - "# Caching\n\nBody.\n" - ); -} - -describe("generateLLMFullContextFiles — nested topics", () => { +describe("generateLLMFullContextFiles", () => { it("emits sub-routers and leaves at nested paths", async () => { const projectDir = await createTempProject(); - await seedOutDir(projectDir); + await seedDocs(projectDir, [ + { + relativePath: "frameworks/react/quickstart.md", + frontmatter: + "title: React Quickstart\ndescription: React.\ngroup: react", + body: "# React Quickstart\n\nBody.\n", + }, + { + relativePath: "frameworks/next/quickstart.md", + frontmatter: + "title: Next.js Quickstart\ndescription: Next.js.\ngroup: next", + body: "# Next.js Quickstart\n\nBody.\n", + }, + ]); await generateLLMFullContextFiles({ outDir: projectDir, baseUrl: "https://c15t.com", product: { name: "c15t" }, - topics: [ + groups: [ { slug: "frameworks", title: "Frameworks", description: "Framework integrations.", - topics: [ + children: [ { slug: "react", title: "React", description: "React integration.", - includePrefixes: ["frameworks/react/"], }, { slug: "next", title: "Next.js", description: "Next.js integration.", - includePrefixes: ["frameworks/next/"], - }, - ], - }, - { - slug: "self-host", - title: "Self-host", - description: "Self-hosting context.", - topics: [ - { - slug: "api", - title: "API Reference", - description: "Backend API reference.", - includePrefixes: ["self-host/api/"], - }, - { - slug: "guides", - title: "Guides", - description: "Self-hosting how-to.", - includePrefixes: ["self-host/guides/"], }, ], }, @@ -192,23 +223,14 @@ describe("generateLLMFullContextFiles — nested topics", () => { path.join(projectDir, "docs", "llms-full.txt"), "utf8" ); - - expect(rootRouter).toContain( - "[Frameworks](./llms-full/frameworks.txt): Framework integrations." - ); - expect(rootRouter).toContain( - " - [React](./llms-full/frameworks/react.txt): React integration." - ); - expect(rootRouter).toContain( - " - [Next.js](./llms-full/frameworks/next.txt): Next.js integration." - ); + expect(rootRouter).toContain("Frameworks"); const frameworksRouter = await readFile( path.join(projectDir, "docs", "llms-full", "frameworks.txt"), "utf8" ); expect(frameworksRouter).toContain("# c15t Frameworks Full Context"); - expect(frameworksRouter).toContain("[React](./frameworks/react.txt)"); + expect(frameworksRouter).toContain("React"); const reactLeaf = await readFile( path.join(projectDir, "docs", "llms-full", "frameworks", "react.txt"), @@ -226,58 +248,64 @@ describe("generateLLMFullContextFiles — nested topics", () => { expect(nextLeaf).not.toContain("React Quickstart"); }); - it("still accepts flat topics (backwards compat)", async () => { + it("inlines a multi-group page in every named leaf", async () => { const projectDir = await createTempProject(); - await seedOutDir(projectDir); + await seedDocs(projectDir, [ + { + relativePath: "rate-limiting.md", + frontmatter: + "title: Rate Limiting\ndescription: Shared rate-limit reference.\ngroup:\n - search\n - self-host", + body: "# Rate Limiting\n\nShared body.\n", + }, + ]); await generateLLMFullContextFiles({ outDir: projectDir, baseUrl: "https://c15t.com", product: { name: "c15t" }, - topics: [ + groups: [ + { slug: "search", title: "Search", description: "Search APIs." }, { - slug: "frameworks", - title: "Frameworks", - description: "All framework docs.", - includePrefixes: ["frameworks/"], + slug: "self-host", + title: "Self-host", + description: "Self-host docs.", }, ], }); - const flatLeaf = await readFile( - path.join(projectDir, "docs", "llms-full", "frameworks.txt"), + const searchLeaf = await readFile( + path.join(projectDir, "docs", "llms-full", "search.txt"), "utf8" ); - expect(flatLeaf).toContain("React Quickstart"); - expect(flatLeaf).toContain("Next.js Quickstart"); - expect( - existsSync( - path.join(projectDir, "docs", "llms-full", "frameworks", "react.txt") - ) - ).toBe(false); + const selfHostLeaf = await readFile( + path.join(projectDir, "docs", "llms-full", "self-host.txt"), + "utf8" + ); + expect(searchLeaf).toContain("Rate Limiting"); + expect(selfHostLeaf).toContain("Rate Limiting"); }); - it("clears stale nested topic files before rewriting the topic tree", async () => { + it("clears stale nested files before rewriting the group tree", async () => { const projectDir = await createTempProject(); - await seedOutDir(projectDir); + await seedDocs(projectDir, [ + { + relativePath: "frameworks/react/quickstart.md", + frontmatter: + "title: React Quickstart\ndescription: React.\ngroup: react", + body: "# React Quickstart\n", + }, + ]); await generateLLMFullContextFiles({ outDir: projectDir, baseUrl: "https://c15t.com", product: { name: "c15t" }, - topics: [ + groups: [ { slug: "frameworks", title: "Frameworks", - description: "Framework integrations.", - topics: [ - { - slug: "react", - title: "React", - description: "React integration.", - includePrefixes: ["frameworks/react/"], - }, - ], + description: "Frameworks.", + children: [{ slug: "react", title: "React", description: "React." }], }, ], }); @@ -288,17 +316,13 @@ describe("generateLLMFullContextFiles — nested topics", () => { ) ).toBe(true); + // Rerun with a flatter shape; the nested react.txt must be removed. await generateLLMFullContextFiles({ outDir: projectDir, baseUrl: "https://c15t.com", product: { name: "c15t" }, - topics: [ - { - slug: "frameworks", - title: "Frameworks", - description: "All framework docs.", - includePrefixes: ["frameworks/"], - }, + groups: [ + { slug: "frameworks", title: "Frameworks", description: "Flat." }, ], }); @@ -309,120 +333,113 @@ describe("generateLLMFullContextFiles — nested topics", () => { ).toBe(false); }); - it("rejects a topic that declares both includePrefixes and topics", async () => { + it("rejects duplicate sibling group slugs (case-insensitive)", async () => { const projectDir = await createTempProject(); - await seedOutDir(projectDir); + await seedDocs(projectDir, [ + { + relativePath: "page.md", + frontmatter: "title: Page\ndescription: Page.\ngroup: react", + body: "# Page\n", + }, + ]); await expect( generateLLMFullContextFiles({ outDir: projectDir, baseUrl: "https://c15t.com", product: { name: "c15t" }, - topics: [ + groups: [ { slug: "frameworks", title: "Frameworks", - description: "Mixed.", - includePrefixes: ["frameworks/"], - topics: [ - { - slug: "react", - title: "React", - description: "React.", - includePrefixes: ["frameworks/react/"], - }, + description: "Frameworks.", + children: [ + { slug: "React", title: "React", description: "React." }, + { slug: "react", title: "React duplicate", description: "Dup." }, ], }, ], }) - ).rejects.toThrow(/parent \(router\) or a leaf \(content\)/); + ).rejects.toThrow(/Duplicate group slug "react" under "frameworks"/i); }); - it("rejects a topic with neither includePrefixes nor topics", async () => { + it("rejects an invalid group slug shape", async () => { const projectDir = await createTempProject(); - await seedOutDir(projectDir); + await seedDocs(projectDir, [ + { + relativePath: "page.md", + frontmatter: "title: Page\ndescription: Page.\ngroup: ok", + body: "# Page\n", + }, + ]); await expect( generateLLMFullContextFiles({ outDir: projectDir, baseUrl: "https://c15t.com", product: { name: "c15t" }, - topics: [ - { - slug: "empty", - title: "Empty", - description: "Nothing.", - }, - ], + groups: [{ slug: "Bad/Slug", title: "Bad", description: "Bad." }], }) - ).rejects.toThrow(/must declare content/); + ).rejects.toThrow(/Invalid group slug/); }); +}); - it("rejects duplicate sibling topic slugs", async () => { +describe("resolveDocsNavigation", () => { + it("returns the group tree, attached pages, and unknown-group references", async () => { const projectDir = await createTempProject(); - await seedOutDir(projectDir); + await seedDocs(projectDir, [ + { + relativePath: "frameworks/react.mdx", + frontmatter: "title: React\ndescription: React.\ngroup: react", + }, + { + relativePath: "frameworks/next.mdx", + frontmatter: "title: Next.js\ndescription: Next.\ngroup: next", + }, + { + relativePath: "rate-limiting.mdx", + frontmatter: + "title: Rate Limit\ndescription: Shared.\ngroup:\n - react\n - mystery", + }, + { + relativePath: "ungrouped.mdx", + frontmatter: "title: Ungrouped\ndescription: No group.", + }, + ]); - await expect( - generateLLMFullContextFiles({ - outDir: projectDir, - baseUrl: "https://c15t.com", - product: { name: "c15t" }, - topics: [ - { - slug: "frameworks", - title: "Frameworks", - description: "Framework integrations.", - topics: [ - { - slug: "react", - title: "React", - description: "React integration.", - includePrefixes: ["frameworks/react/"], - }, - { - slug: "react", - title: "React duplicate", - description: "Duplicate React integration.", - includePrefixes: ["frameworks/next/"], - }, - ], - }, - ], - }) - ).rejects.toThrow(/Duplicate topic slug "react" under "frameworks"/); - }); + const nav = await resolveDocsNavigation({ + srcDir: projectDir, + baseUrl: "https://c15t.com", + groups: [ + { + slug: "frameworks", + title: "Frameworks", + description: "Frameworks.", + children: [ + { slug: "react", title: "React", description: "React." }, + { slug: "next", title: "Next.js", description: "Next.js." }, + ], + }, + ], + }); - it("rejects duplicate sibling topic slugs case-insensitively", async () => { - const projectDir = await createTempProject(); - await seedOutDir(projectDir); + expect(nav.groups).toHaveLength(1); + expect(nav.groups[0]?.slug).toBe("frameworks"); + expect(nav.groups[0]?.children.map((c) => c.slug)).toEqual([ + "react", + "next", + ]); - await expect( - generateLLMFullContextFiles({ - outDir: projectDir, - baseUrl: "https://c15t.com", - product: { name: "c15t" }, - topics: [ - { - slug: "frameworks", - title: "Frameworks", - description: "Framework integrations.", - topics: [ - { - slug: "React", - title: "React", - description: "React integration.", - includePrefixes: ["frameworks/react/"], - }, - { - slug: "react", - title: "React duplicate", - description: "Duplicate React integration.", - includePrefixes: ["frameworks/next/"], - }, - ], - }, - ], - }) - ).rejects.toThrow(/Duplicate topic slug "react" under "frameworks"/i); + const reactPages = nav.groups[0]?.children[0]?.pages.map((p) => p.title); + expect(reactPages).toContain("React"); + expect(reactPages).toContain("Rate Limit"); + + const ungroupedTitles = nav.ungrouped.map((p) => p.title); + expect(ungroupedTitles).toContain("Ungrouped"); + + expect(nav.unknown).toContainEqual({ + urlPath: "/docs/rate-limiting", + slug: "mystery", + }); }); }); diff --git a/packages/docs/src/llm/llm.ts b/packages/docs/src/llm/llm.ts index 71f2989..cefe5d4 100644 --- a/packages/docs/src/llm/llm.ts +++ b/packages/docs/src/llm/llm.ts @@ -4,12 +4,13 @@ import path from "node:path"; import matter from "gray-matter"; const DOCS_DIRNAME = "docs"; -const TOPIC_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/i; +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/i; -function assertValidTopicSlug(slug: string): string { - if (!TOPIC_SLUG_PATTERN.test(slug)) { +function assertValidGroupSlug(slug: string, parentPath: string[]): string { + if (!SLUG_PATTERN.test(slug)) { + const scope = parentPath.join("/") || "root"; throw new Error( - `Invalid topic slug "${slug}". Slugs must be a single URL-safe path segment (alphanumerics and dashes).` + `Invalid group slug "${slug}" under "${scope}". Slugs must be URL-safe (alphanumerics and dashes).` ); } return slug; @@ -35,6 +36,8 @@ export type SourceDoc = { urlPath: string; absoluteUrl: string; relativePath: string; + /** Group slugs declared in frontmatter `group:`. Empty array = ungrouped. */ + groups: string[]; }; export type MarkdownDoc = SourceDoc & { @@ -47,49 +50,6 @@ export type CuratedLink = { description?: string; }; -export type CuratedSection = { - title: string; - description?: string; - links: CuratedLink[]; -}; - -export type FullTopic = { - slug: string; - title: string; - description: string; - /** - * Leaf topic: page prefixes (relative to `{outDir}/docs/`) whose markdown - * should be inlined into this topic's `.txt`. Mutually exclusive with - * `topics` — a topic is either a leaf (content) or a parent (router). - */ - includePrefixes?: string[]; - /** - * Parent topic: nested sub-topics. Generates a router `.txt` linking to - * each child, and each child's file lives under `{slug}/{childSlug}.txt`. - */ - topics?: FullTopic[]; -}; - -type ResolvedLeafTopic = { - kind: "leaf"; - slug: string; - title: string; - description: string; - segmentPath: string[]; - includePrefixes: string[]; -}; - -type ResolvedParentTopic = { - kind: "parent"; - slug: string; - title: string; - description: string; - segmentPath: string[]; - children: ResolvedTopic[]; -}; - -type ResolvedTopic = ResolvedLeafTopic | ResolvedParentTopic; - export type ProductInfo = { /** Product display name, e.g. "DSAR SDK" */ name: string; @@ -103,22 +63,116 @@ export type ProductInfo = { agentGuidance?: string; }; +/** + * One entry in a docs navigation group tree. A group with `children` is a + * router (parent); a group without `children` is a leaf and can directly + * contain pages whose frontmatter `group:` matches its slug. + */ +export type DocsGroup = { + slug: string; + title: string; + description?: string; + children?: DocsGroup[]; +}; + +/** + * Combined config for the `@inth/docs` docs-generation pipeline. Pass to + * `defineDocsConfig` in a `docs.config.ts` file. Pages declare which group + * they belong to via MDX frontmatter (`group: ` or `group: [a, b]`), + * so this config only describes the structure and metadata of groups, not + * per-page membership. + */ +export type DocsConfig = { + product: ProductInfo; + groups: DocsGroup[]; +}; + +/** + * Identity helper that gives the config object full IDE autocomplete and + * type-checks the docs structure at edit time. + */ +export function defineDocsConfig(config: DocsConfig): DocsConfig { + return config; +} + export type LlmsTxtConfig = { srcDir: string; outDir: string; baseUrl?: string; product: ProductInfo; - /** Sections rendered in /docs/llms.txt */ - docsSections?: CuratedSection[]; + /** Group tree from `docs.config.ts`. Used for `/docs/llms.txt` sections. */ + groups: DocsGroup[]; }; export type LLMFullContextConfig = { outDir: string; baseUrl?: string; product: Pick; - topics: FullTopic[]; + /** Group tree from `docs.config.ts`. Each leaf group becomes a `.txt`. */ + groups: DocsGroup[]; +}; + +type ResolvedGroup = { + slug: string; + slugKey: string; + title: string; + description?: string; + segmentPath: string[]; + parent: ResolvedGroup | null; + children: ResolvedGroup[]; }; +function resolveGroups( + groups: DocsGroup[], + parentPath: string[] = [], + parent: ResolvedGroup | null = null +): ResolvedGroup[] { + const seen = new Set(); + return groups.map((group) => { + const slug = assertValidGroupSlug(group.slug, parentPath); + const slugKey = slug.toLowerCase(); + if (seen.has(slugKey)) { + const scope = parentPath.join("/") || "root"; + throw new Error( + `Duplicate group slug "${slug}" under "${scope}". Group slugs must be unique among siblings.` + ); + } + seen.add(slugKey); + + const segmentPath = [...parentPath, slug]; + const resolved: ResolvedGroup = { + slug, + slugKey, + title: group.title, + description: group.description, + segmentPath, + parent, + children: [], + }; + resolved.children = resolveGroups( + group.children ?? [], + segmentPath, + resolved + ); + return resolved; + }); +} + +function flattenGroups(groups: ResolvedGroup[]): ResolvedGroup[] { + const result: ResolvedGroup[] = []; + for (const group of groups) { + result.push(group); + if (group.children.length > 0) { + result.push(...flattenGroups(group.children)); + } + } + return result; +} + +function isLeafGroup(group: ResolvedGroup): boolean { + return group.children.length === 0; +} + function titleize(input: string): string { return input .split(SEPARATOR_PATTERN) @@ -131,15 +185,6 @@ function normalizeDescription(input: string): string { return input.replace(WHITESPACE_PATTERN, " ").trim(); } -function titleFromUrlPath(urlPath: string): string { - const segments = urlPath.split("/").filter(Boolean); - const lastSegment = segments.at(-1); - if (!lastSegment || lastSegment === "docs") { - return "Documentation"; - } - return titleize(lastSegment); -} - function titleFromRelativePath( relativePath: string, extension: ".md" | ".mdx" @@ -156,34 +201,6 @@ function titleFromRelativePath( return titleize(segment); } -function resolveLinkTitle(link: CuratedLink, sourceDoc?: SourceDoc): string { - if (link.title) { - return link.title; - } - - const sourceTitle = sourceDoc?.title?.trim(); - if (sourceTitle && !GENERIC_DOC_TITLES.has(sourceTitle.toLowerCase())) { - return sourceTitle; - } - - return titleFromUrlPath(sourceDoc?.urlPath ?? link.urlPath); -} - -function resolveLinkDescription( - link: CuratedLink, - title: string, - sourceDoc?: SourceDoc -): string { - const sourceDescription = normalizeDescription(sourceDoc?.description ?? ""); - if (link.description) { - return link.description; - } - if (sourceDescription) { - return sourceDescription; - } - return `Entry point for ${title} documentation.`; -} - function normalizeBaseUrl(baseUrl?: string): string { const resolved = baseUrl?.trim() || @@ -232,11 +249,25 @@ function toAbsoluteUrl(urlPath: string, baseUrl: string): string { return `${baseUrl}${urlPath}`; } -function isIncluded(relativePath: string, prefixes: string[]): boolean { - return prefixes.some((raw) => { - const prefix = raw.replace(TRAILING_SLASHES_PATTERN, ""); - return relativePath === prefix || relativePath.startsWith(`${prefix}/`); - }); +function normalizeGroupValue(raw: unknown): string[] { + if (typeof raw === "string") { + const trimmed = raw.trim(); + return trimmed ? [trimmed] : []; + } + if (Array.isArray(raw)) { + const normalized: string[] = []; + for (const item of raw) { + if (typeof item !== "string") { + continue; + } + const trimmed = item.trim(); + if (trimmed) { + normalized.push(trimmed); + } + } + return normalized; + } + return []; } type RenderedLink = { @@ -249,16 +280,43 @@ function renderLink(link: RenderedLink): string { return `- [${link.title}](${link.absoluteUrl}): ${link.description}`; } -function renderSection( - section: CuratedSection, - resolvedLinks: RenderedLink[] -): string { - const lines = [`## ${section.title}`]; - if (section.description) { - lines.push("", section.description); - } - lines.push("", ...resolvedLinks.map(renderLink)); - return lines.join("\n"); +function pageToRenderedLink(doc: SourceDoc): RenderedLink { + const title = + doc.title && !GENERIC_DOC_TITLES.has(doc.title.toLowerCase()) + ? doc.title + : titleize(doc.relativePath.split("/").pop() ?? "Documentation"); + const description = + normalizeDescription(doc.description) || + `Reference page for ${title.toLowerCase()}.`; + return { + title, + description, + absoluteUrl: doc.absoluteUrl, + }; +} + +function resolveCuratedLink( + link: CuratedLink, + sourceDocs: Map, + baseUrl: string +): RenderedLink { + const sourceDoc = sourceDocs.get(link.urlPath); + const title = + link.title ?? + (sourceDoc?.title && !GENERIC_DOC_TITLES.has(sourceDoc.title.toLowerCase()) + ? sourceDoc.title + : titleize( + link.urlPath.split("/").filter(Boolean).pop() ?? "Documentation" + )); + const description = + link.description ?? + normalizeDescription(sourceDoc?.description ?? "") ?? + `Entry point for ${title} documentation.`; + return { + title, + description: description || `Entry point for ${title} documentation.`, + absoluteUrl: toAbsoluteUrl(sourceDoc?.urlPath ?? link.urlPath, baseUrl), + }; } async function collectFiles( @@ -311,6 +369,7 @@ async function readSourceDocs( String(parsed.data.description ?? "") ); const urlPath = toUrlPath(relativePath); + const groups = normalizeGroupValue(parsed.data.group); return { urlPath, doc: { @@ -319,6 +378,7 @@ async function readSourceDocs( urlPath, absoluteUrl: toAbsoluteUrl(urlPath, baseUrl), relativePath: relativePath.replace(MD_EXTENSION_PATTERN, ""), + groups, }, }; }) @@ -362,6 +422,7 @@ async function readMarkdownDocs( String(parsed.data.description ?? "") ); const urlPath = toUrlPath(relativePath); + const groups = normalizeGroupValue(parsed.data.group); return { title, @@ -369,6 +430,7 @@ async function readMarkdownDocs( urlPath, absoluteUrl: toAbsoluteUrl(urlPath, baseUrl), relativePath: relativePath.replace(MD_ONLY_EXTENSION_PATTERN, ""), + groups, content: parsed.content.trim(), }; }) @@ -377,18 +439,76 @@ async function readMarkdownDocs( return docs.sort((left, right) => left.urlPath.localeCompare(right.urlPath)); } -function resolveCuratedLink( - link: CuratedLink, - sourceDocs: Map, - baseUrl: string -): RenderedLink { - const sourceDoc = sourceDocs.get(link.urlPath); - const title = resolveLinkTitle(link, sourceDoc); - return { - title, - description: resolveLinkDescription(link, title, sourceDoc), - absoluteUrl: toAbsoluteUrl(sourceDoc?.urlPath ?? link.urlPath, baseUrl), - }; +type GroupMembership = { + /** Map from group slug (lowercased) → pages whose `group:` lists that slug. */ + byGroupSlug: Map; + /** Pages whose frontmatter has no `group:`. */ + ungrouped: SourceDoc[]; + /** Pages that named a group slug not present in the config. */ + unknown: { page: SourceDoc; slug: string }[]; +}; + +function buildGroupMembership( + pages: SourceDoc[], + resolved: ResolvedGroup[] +): GroupMembership { + const all = flattenGroups(resolved); + const known = new Map(all.map((g) => [g.slugKey, g])); + const byGroupSlug = new Map(); + const ungrouped: SourceDoc[] = []; + const unknown: { page: SourceDoc; slug: string }[] = []; + + // Stable page order: by urlPath. Inputs are already iteration-order-stable + // when they come from a Map in insertion order, but explicit sort makes + // the rendered llms.txt deterministic regardless of source. + const ordered = [...pages].sort((left, right) => + left.urlPath.localeCompare(right.urlPath) + ); + + for (const page of ordered) { + if (page.groups.length === 0) { + ungrouped.push(page); + continue; + } + let matchedAny = false; + for (const slug of page.groups) { + const slugKey = slug.toLowerCase(); + if (!known.has(slugKey)) { + unknown.push({ page, slug }); + continue; + } + const list = byGroupSlug.get(slugKey) ?? []; + list.push(page); + byGroupSlug.set(slugKey, list); + matchedAny = true; + } + if (!matchedAny) { + ungrouped.push(page); + } + } + + return { byGroupSlug, ungrouped, unknown }; +} + +/** Pages whose `group:` includes the slug of `target` or any descendant. */ +function pagesUnderGroup( + target: ResolvedGroup, + membership: GroupMembership +): SourceDoc[] { + const seen = new Set(); + const collected: SourceDoc[] = []; + const stack = [target, ...flattenGroups(target.children)]; + for (const group of stack) { + const list = membership.byGroupSlug.get(group.slugKey) ?? []; + for (const page of list) { + if (seen.has(page.urlPath)) { + continue; + } + seen.add(page.urlPath); + collected.push(page); + } + } + return collected; } function renderProductSummary( @@ -425,16 +545,31 @@ function renderProductSummary( function renderDocsSummary( product: ProductInfo, - sourceDocs: Map, - baseUrl: string, - docsSections: CuratedSection[] + resolved: ResolvedGroup[], + membership: GroupMembership ): string { - const sections = docsSections.map((section) => - renderSection( - section, - section.links.map((link) => resolveCuratedLink(link, sourceDocs, baseUrl)) - ) - ); + const renderedSections: string[] = []; + for (const group of resolved) { + const pages = pagesUnderGroup(group, membership); + if (pages.length === 0) { + continue; + } + const lines: string[] = [`## ${group.title}`]; + if (group.description) { + lines.push("", group.description); + } + lines.push("", ...pages.map(pageToRenderedLink).map(renderLink)); + renderedSections.push(lines.join("\n")); + } + + if (membership.ungrouped.length > 0) { + const lines = ["## Other"]; + lines.push( + "", + ...membership.ungrouped.map(pageToRenderedLink).map(renderLink) + ); + renderedSections.push(lines.join("\n")); + } return `# ${product.name} Documentation @@ -444,65 +579,7 @@ function renderDocsSummary( Read the summary links first. If the summary is not enough, choose the smallest relevant topic file from \`/docs/llms-full.txt\`. -${sections.join("\n\n")}`; -} - -function resolveTopics( - topics: FullTopic[], - parentPath: string[] = [] -): ResolvedTopic[] { - const seenSlugs = new Set(); - - return topics.map((topic) => { - const slug = assertValidTopicSlug(topic.slug); - const slugKey = slug.toLowerCase(); - - if (seenSlugs.has(slugKey)) { - const scope = parentPath.join("/") || "root"; - throw new Error( - `Duplicate topic slug "${slug}" under "${scope}". Topic slugs must be unique among siblings.` - ); - } - seenSlugs.add(slugKey); - - const segmentPath = [...parentPath, slug]; - - const hasChildren = topic.topics && topic.topics.length > 0; - const hasLeafPrefixes = - topic.includePrefixes && topic.includePrefixes.length > 0; - - if (hasChildren && hasLeafPrefixes) { - throw new Error( - `Topic "${segmentPath.join("/")}" has both \`topics\` and \`includePrefixes\`. A topic must be either a parent (router) or a leaf (content), not both.` - ); - } - if (!(hasChildren || hasLeafPrefixes)) { - throw new Error( - `Topic "${segmentPath.join("/")}" has neither \`topics\` nor \`includePrefixes\`. A topic must declare content (\`includePrefixes\`) or sub-topics (\`topics\`).` - ); - } - - if (hasChildren) { - return { - kind: "parent", - slug, - title: topic.title, - description: topic.description, - segmentPath, - // biome-ignore lint/style/noNonNullAssertion: checked above via hasChildren - children: resolveTopics(topic.topics!, segmentPath), - }; - } - return { - kind: "leaf", - slug, - title: topic.title, - description: topic.description, - segmentPath, - // biome-ignore lint/style/noNonNullAssertion: checked above via hasLeafPrefixes - includePrefixes: topic.includePrefixes!, - }; - }); +${renderedSections.join("\n\n")}`; } function topicFilePath(segmentPath: string[]): string { @@ -529,25 +606,26 @@ function toRelativeRouterLink( return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; } -function renderTopicRouterLinks( - topics: ResolvedTopic[], +function renderGroupRouterLinks( + groups: ResolvedGroup[], currentSegmentPath: string[], indentLevel = 0 ): string[] { const indent = " ".repeat(indentLevel); const lines: string[] = []; - for (const topic of topics) { + for (const group of groups) { const relativeUrl = toRelativeRouterLink( currentSegmentPath, - topic.segmentPath + group.segmentPath ); + const description = group.description ?? ""; lines.push( - `${indent}- [${topic.title}](${relativeUrl}): ${topic.description}` + `${indent}- [${group.title}](${relativeUrl})${description ? `: ${description}` : ""}` ); - if (topic.kind === "parent") { + if (!isLeafGroup(group)) { lines.push( - ...renderTopicRouterLinks( - topic.children, + ...renderGroupRouterLinks( + group.children, currentSegmentPath, indentLevel + 1 ) @@ -559,7 +637,7 @@ function renderTopicRouterLinks( function renderDocsFullRouter( product: Pick, - topics: ResolvedTopic[] + resolved: ResolvedGroup[] ): string { return [ `# ${product.name} Documentation Full Context`, @@ -568,22 +646,22 @@ function renderDocsFullRouter( "", "## Topics", "", - ...renderTopicRouterLinks(topics, []), + ...renderGroupRouterLinks(resolved, []), ].join("\n"); } -function renderTopicSubRouter( +function renderGroupSubRouter( product: Pick, - parent: ResolvedParentTopic + parent: ResolvedGroup ): string { return [ `# ${product.name} ${parent.title} Full Context`, "", - `> ${parent.description}`, + `> ${parent.description ?? ""}`, "", "## Topics", "", - ...renderTopicRouterLinks(parent.children, parent.segmentPath), + ...renderGroupRouterLinks(parent.children, parent.segmentPath), ].join("\n"); } @@ -612,21 +690,21 @@ function renderRootFullRouter( return lines.join("\n"); } -function renderTopicDocument( +function renderLeafGroupDocument( product: Pick, - topic: ResolvedLeafTopic, - docs: MarkdownDoc[] + leaf: ResolvedGroup, + pages: MarkdownDoc[] ): string { - const topicDocs = docs.filter((doc) => - isIncluded(doc.relativePath, topic.includePrefixes) + const groupPages = pages.filter((page) => + page.groups.some((slug) => slug.toLowerCase() === leaf.slugKey) ); - const links = topicDocs.map((doc) => ({ + const links = groupPages.map((doc) => ({ title: doc.title, absoluteUrl: doc.absoluteUrl, description: doc.description || `Entry point for ${doc.title} documentation.`, })); - const contentBlocks = topicDocs.map((doc) => { + const contentBlocks = groupPages.map((doc) => { const description = doc.description ? `${doc.description}\n` : ""; return `# ${doc.title} URL: ${doc.absoluteUrl} @@ -635,13 +713,15 @@ ${doc.content}`.trim(); }); return [ - `# ${product.name} ${topic.title} Full Context`, + `# ${product.name} ${leaf.title} Full Context`, "", - `> ${topic.description}`, + `> ${leaf.description ?? ""}`, "", "## Included Pages", "", - links.map(renderLink).join("\n"), + links.length > 0 + ? links.map(renderLink).join("\n") + : "_No pages declare this group in their frontmatter._", "", "## Content", "", @@ -649,37 +729,29 @@ ${doc.content}`.trim(); ].join("\n"); } -async function writeTopicTree( - topics: ResolvedTopic[], +async function writeGroupTree( + groups: ResolvedGroup[], product: Pick, - baseUrl: string, markdownDocs: MarkdownDoc[], llmsFullDir: string ): Promise { - for (const topic of topics) { + for (const group of groups) { const filePath = path.join( llmsFullDir, - ...topic.segmentPath.slice(0, -1), - `${topic.slug}.txt` + ...group.segmentPath.slice(0, -1), + `${group.slug}.txt` ); await mkdir(path.dirname(filePath), { recursive: true }); - if (topic.kind === "parent") { - await writeFile(filePath, renderTopicSubRouter(product, topic)); - await writeTopicTree( - topic.children, - product, - baseUrl, - markdownDocs, - llmsFullDir + if (isLeafGroup(group)) { + await writeFile( + filePath, + renderLeafGroupDocument(product, group, markdownDocs) ); continue; } - - await writeFile( - filePath, - renderTopicDocument(product, topic, markdownDocs) - ); + await writeFile(filePath, renderGroupSubRouter(product, group)); + await writeGroupTree(group.children, product, markdownDocs, llmsFullDir); } } @@ -693,28 +765,27 @@ export async function generateLlmsTxt(config: LlmsTxtConfig): Promise { const baseUrl = normalizeBaseUrl(config.baseUrl); const sourceDocs = await readSourceDocs(srcDir, baseUrl); + const resolved = resolveGroups(config.groups); + const membership = buildGroupMembership([...sourceDocs.values()], resolved); + await mkdir(path.join(outDir, DOCS_DIRNAME), { recursive: true }); await writeFile( path.join(outDir, "llms.txt"), renderProductSummary(config.product, sourceDocs, baseUrl) ); - if (config.docsSections && config.docsSections.length > 0) { + if (resolved.length > 0) { await writeFile( path.join(outDir, DOCS_DIRNAME, "llms.txt"), - renderDocsSummary( - config.product, - sourceDocs, - baseUrl, - config.docsSections - ) + renderDocsSummary(config.product, resolved, membership) ); } } /** - * Generate the full-context routers and one topic-specific .txt per topic - * under `/docs/llms-full/`. Reads generated .md files from `{outDir}/docs/`. + * Generate the full-context routers and one topic-specific .txt per leaf + * group under `/docs/llms-full/`. Reads generated .md files from + * `{outDir}/docs/`. */ export async function generateLLMFullContextFiles( config: LLMFullContextConfig @@ -723,21 +794,14 @@ export async function generateLLMFullContextFiles( const baseUrl = normalizeBaseUrl(config.baseUrl); const markdownDocs = await readMarkdownDocs(outDir, baseUrl); - // Fail fast if there's nothing to index — usually means convertAllMdx - // didn't run (or wrote to a different outDir) and we'd otherwise ship - // hollow router/topic files. if (markdownDocs.length === 0) { throw new Error( `generateLLMFullContextFiles found no markdown under "${path.join(outDir, DOCS_DIRNAME)}". Run convertAllMdx first, or check that config.outDir matches.` ); } - // Resolve the (possibly nested) topic tree. Slugs are validated here — - // they're interpolated into both URLs and file paths, so values with `/`, - // `..`, whitespace, etc. are a security footgun. - const resolvedTopics = resolveTopics(config.topics); + const resolved = resolveGroups(config.groups); - // Only advertise the docs summary link if that file is guaranteed to exist. const hasDocsSummary = existsSync( path.join(outDir, DOCS_DIRNAME, "llms.txt") ); @@ -751,14 +815,91 @@ export async function generateLLMFullContextFiles( ); await writeFile( path.join(outDir, DOCS_DIRNAME, "llms-full.txt"), - renderDocsFullRouter(config.product, resolvedTopics) + renderDocsFullRouter(config.product, resolved) ); - await writeTopicTree( - resolvedTopics, - config.product, - baseUrl, - markdownDocs, - llmsFullDir - ); + await writeGroupTree(resolved, config.product, markdownDocs, llmsFullDir); +} + +/* ---------------- Navigation manifest ----------------------------------- */ + +export type DocsNavigationPage = { + urlPath: string; + title: string; + description: string; + /** All group slugs the page declared (normalized). */ + groups: string[]; +}; + +export type DocsNavigationGroup = { + slug: string; + segmentPath: string[]; + title: string; + description?: string; + pages: DocsNavigationPage[]; + children: DocsNavigationGroup[]; +}; + +export type DocsNavigation = { + groups: DocsNavigationGroup[]; + ungrouped: DocsNavigationPage[]; + /** Pages that named a group slug not present in the config. */ + unknown: { urlPath: string; slug: string }[]; +}; + +export type ResolveDocsNavigationConfig = { + srcDir: string; + baseUrl?: string; + groups: DocsGroup[]; +}; + +function pageView(doc: SourceDoc): DocsNavigationPage { + return { + urlPath: doc.urlPath, + title: doc.title, + description: doc.description, + groups: [...doc.groups], + }; +} + +function buildNavigationGroup( + group: ResolvedGroup, + membership: GroupMembership +): DocsNavigationGroup { + const directPages = membership.byGroupSlug.get(group.slugKey) ?? []; + return { + slug: group.slug, + segmentPath: group.segmentPath, + title: group.title, + description: group.description, + pages: directPages.map(pageView), + children: group.children.map((child) => + buildNavigationGroup(child, membership) + ), + }; +} + +/** + * Walk the docs source tree once and return a structured navigation manifest. + * Build pipelines write this to disk (e.g. `src/generated/docs-nav.json`) + * for the runtime sidebar to import — keeps the docs-config.ts as the single + * source of truth without forcing the runtime to scan MDX itself. + */ +export async function resolveDocsNavigation( + config: ResolveDocsNavigationConfig +): Promise { + const srcDir = path.resolve(config.srcDir); + const baseUrl = normalizeBaseUrl(config.baseUrl); + const sourceDocs = await readSourceDocs(srcDir, baseUrl); + const resolved = resolveGroups(config.groups); + const membership = buildGroupMembership([...sourceDocs.values()], resolved); + + return { + groups: resolved.map((group) => buildNavigationGroup(group, membership)), + ungrouped: membership.ungrouped.map(pageView), + unknown: membership.unknown.map(({ page, slug }) => ({ + urlPath: page.urlPath, + slug, + })), + }; } diff --git a/packages/docs/tsup.config.ts b/packages/docs/tsup.config.ts index f86cdbe..62b4b97 100644 --- a/packages/docs/tsup.config.ts +++ b/packages/docs/tsup.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: { + index: "src/index.ts", "remark/index": "src/remark/index.ts", "convert/index": "src/convert/index.ts", "llm/index": "src/llm/index.ts", @@ -13,7 +14,7 @@ export default defineConfig({ "search/tanstack-index": "src/search/tanstack-index.ts", "search/cloudflare-index": "src/search/cloudflare-index.ts", "lint/index": "src/lint/index.ts", - "lint/cli": "src/lint/cli.ts", + cli: "src/cli.ts", }, format: ["esm"], dts: true, @@ -24,7 +25,7 @@ export default defineConfig({ treeshake: true, onSuccess: async () => { const { chmod, readFile, writeFile } = await import("node:fs/promises"); - const cli = "dist/lint/cli.js"; + const cli = "dist/cli.js"; const contents = await readFile(cli, "utf8"); if (!contents.startsWith("#!")) { await writeFile(cli, `#!/usr/bin/env node\n${contents}`); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..88e6d29 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": false, + "noEmit": true, + "isolatedModules": true, + "paths": { + "@inth/docs": ["./packages/docs/src/index.ts"], + "@inth/docs/*": ["./packages/docs/src/*/index.ts"] + } + }, + "include": ["docs/**/*.ts"] +} From 235e775439abd2cc80e95fe99827d6c811e7dbd9 Mon Sep 17 00:00:00 2001 From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:27:20 -0700 Subject: [PATCH 2/3] Address docs PR review comments --- .agents/skills/inth-docs/SKILL.md | 2 +- .github/workflows/bench.yml | 1 + README.md | 1 + apps/example/playwright.config.ts | 2 + apps/example/scripts/bench.ts | 53 ++++----- apps/example/scripts/llm-generate.ts | 2 + apps/example/src/components/docs-shell.tsx | 5 +- apps/example/src/components/search-bar.tsx | 14 ++- apps/example/src/generated/docs-nav.json | 101 +++++++++++++----- .../src/generated/docs-search-content.json | 2 +- .../src/generated/docs-search-index.json | 2 +- apps/example/src/lib/use-docs-search.ts | 9 +- apps/example/src/routes/__root.tsx | 11 +- apps/example/src/routes/docs/llm.tsx | 2 +- apps/example/src/routes/playground.tsx | 2 +- apps/example/src/routes/search.tsx | 67 ++++++++---- apps/example/tests/e2e/smoke.e2e.ts | 28 ++++- apps/example/tsconfig.json | 1 + biome.jsonc | 2 +- docs/convert.mdx | 1 + packages/docs/README.md | 1 + packages/docs/src/cli.test.ts | 84 ++++++++++++++- packages/docs/src/cli.ts | 6 +- packages/docs/src/cli/generate.ts | 86 +++++++++------ 24 files changed, 346 insertions(+), 139 deletions(-) diff --git a/.agents/skills/inth-docs/SKILL.md b/.agents/skills/inth-docs/SKILL.md index 86cb5bd..ac77497 100644 --- a/.agents/skills/inth-docs/SKILL.md +++ b/.agents/skills/inth-docs/SKILL.md @@ -15,7 +15,7 @@ Use the packaged agent docs as reference data. Prefer the installed package copy 1. `node_modules/@inth/docs/docs/llms.txt` 2. `node_modules/@inth/docs/docs/.md` -3. `packages/docs/docs/llms.txt` (generated; run `bun run --filter @inth/docs build` first) +3. `packages/docs/docs/llms.txt` (generated; run `bun run --filter @inth/docs docs:generate` first) 4. `packages/docs/docs/.md` (generated) 5. `docs/.mdx` (repo-root source — fallback when generated output is absent) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 62f2d37..806ea03 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -6,6 +6,7 @@ on: - main paths: - "packages/docs/**" + - "docs/**" - "apps/example/**" - ".github/workflows/bench.yml" diff --git a/README.md b/README.md index ce8b66d..4e1f895 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ The package ships a small, topic-scoped agent reference bundle in `docs/`: - `docs/lint.md` Set `INTH_DOCS_AGENT_BASE_URL` to the hosted docs base before generating publishable `llms*.txt` files. +For the example app generator, base URL precedence is `INTH_DOCS_AGENT_BASE_URL`, then generic deployment `BASE_URL`, then `PORTLESS_URL`, then the local default. ## Repo Skill diff --git a/apps/example/playwright.config.ts b/apps/example/playwright.config.ts index b269534..ee595aa 100644 --- a/apps/example/playwright.config.ts +++ b/apps/example/playwright.config.ts @@ -16,6 +16,8 @@ function getExampleBaseUrl(): string { try { portlessUrl = execFileSync("portless", ["get", "example"], { encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + timeout: 5000, }).trim(); } catch { process.stderr.write( diff --git a/apps/example/scripts/bench.ts b/apps/example/scripts/bench.ts index 3d2903b..0d12298 100644 --- a/apps/example/scripts/bench.ts +++ b/apps/example/scripts/bench.ts @@ -32,6 +32,23 @@ const OUT_DIR = join(process.cwd(), "public-bench"); // LLM gen expects .md files under `{outDir}/docs/`, so convert writes into // `OUT_DIR/docs/` to match the convention. const CONVERT_OUT_DIR = join(OUT_DIR, "docs"); +const BENCH_GROUPS = [ + { + slug: "frameworks", + title: "Frameworks", + description: "Framework-specific guides.", + }, + { + slug: "integrations", + title: "Integrations", + description: "Integration guides.", + }, + { + slug: "self-host", + title: "Self-host", + description: "Self-hosting guides.", + }, +]; if (!existsSync(SRC_DIR)) { process.stderr.write( @@ -91,45 +108,13 @@ async function bench(): Promise { summary: "Benchmark fixture.", bestStartingPoints: [], }, - groups: [ - { - slug: "frameworks", - title: "Frameworks", - description: "Framework-specific guides.", - }, - { - slug: "integrations", - title: "Integrations", - description: "Integration guides.", - }, - { - slug: "self-host", - title: "Self-host", - description: "Self-hosting guides.", - }, - ], + groups: BENCH_GROUPS, }); await generateLLMFullContextFiles({ outDir: OUT_DIR, baseUrl: "https://docs.example.com", product: { name: "Bench SDK" }, - groups: [ - { - slug: "frameworks", - title: "Frameworks", - description: "Framework-specific guides.", - }, - { - slug: "integrations", - title: "Integrations", - description: "Integration guides.", - }, - { - slug: "self-host", - title: "Self-host", - description: "Self-hosting guides.", - }, - ], + groups: BENCH_GROUPS, }); }); diff --git a/apps/example/scripts/llm-generate.ts b/apps/example/scripts/llm-generate.ts index 8b56328..6b5e369 100644 --- a/apps/example/scripts/llm-generate.ts +++ b/apps/example/scripts/llm-generate.ts @@ -25,6 +25,8 @@ const repoRoot = join(appRoot, "..", ".."); const srcDir = repoRoot; const outDir = join(appRoot, "public"); const generatedDir = join(appRoot, "src", "generated"); +// Base URL precedence: package-specific override, generic deployment URL, +// portless local URL, then a stable docs example fallback. const baseUrl = process.env.INTH_DOCS_AGENT_BASE_URL?.trim() || process.env.BASE_URL?.trim() || diff --git a/apps/example/src/components/docs-shell.tsx b/apps/example/src/components/docs-shell.tsx index 942b8a6..dc4736a 100644 --- a/apps/example/src/components/docs-shell.tsx +++ b/apps/example/src/components/docs-shell.tsx @@ -22,7 +22,10 @@ export function DocsShell({ children }: { children: ReactNode }) {

{section.title}

-