From cc7afc353e7ee31fbc8519ff63604495971b7e7f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:45:46 +0000 Subject: [PATCH 1/9] chore(release): v0.1.31 --- CHANGELOG.md | 14 ++++++++++++++ package-lock.json | 6 +++--- packages/cli/package.json | 2 +- packages/docs-site/package.json | 4 ++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c37d3..3b19f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ The project uses Semantic Versioning. Each release contains the same three chang ### New Features +- None. + +### Bug Fixes + +- None. + +### Improvements + +- None. + +## [0.1.31] - 2026-09-12 + +### New Features + - `cortex docs build` now exports static HTML, CSS, JavaScript, and documentation data for deployment to a static web host. ### Bug Fixes diff --git a/package-lock.json b/package-lock.json index cf85a54..f66b368 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18355,7 +18355,7 @@ }, "packages/cli": { "name": "@cortex-docs/cli", - "version": "0.1.30", + "version": "0.1.31", "bundleDependencies": [ "@cortex-docs/core", "@cortex-docs/codegen", @@ -18549,9 +18549,9 @@ }, "packages/docs-site": { "name": "@cortex-docs/docs-site", - "version": "0.1.30", + "version": "0.1.31", "dependencies": { - "@cortex-docs/cli": "0.1.30" + "@cortex-docs/cli": "0.1.31" } }, "packages/docs-ui": { diff --git a/packages/cli/package.json b/packages/cli/package.json index baed299..35baccb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@cortex-docs/cli", - "version": "0.1.30", + "version": "0.1.31", "description": "Cortex Docs CLI for SDKs, API documentation, and MCP servers", "license": "MIT", "repository": { diff --git a/packages/docs-site/package.json b/packages/docs-site/package.json index 1e8a1cb..8e08dad 100644 --- a/packages/docs-site/package.json +++ b/packages/docs-site/package.json @@ -1,6 +1,6 @@ { "name": "@cortex-docs/docs-site", - "version": "0.1.30", + "version": "0.1.31", "private": true, "description": "Product documentation for Cortex Docs", "scripts": { @@ -9,6 +9,6 @@ "clean": "rm -rf .cortex" }, "dependencies": { - "@cortex-docs/cli": "0.1.30" + "@cortex-docs/cli": "0.1.31" } } From c3e2be74ebb13253f4c9e6f40e7bbe692f6793e3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:42:05 +0000 Subject: [PATCH 2/9] chore(release): v0.1.32 --- CHANGELOG.md | 14 ++++++++++++++ package-lock.json | 6 +++--- packages/cli/package.json | 2 +- packages/docs-site/package.json | 4 ++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b19f76..f311885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,20 @@ The project uses Semantic Versioning. Each release contains the same three chang - None. +## [0.1.32] - 2026-09-13 + +### New Features + +- None. + +### Bug Fixes + +- None. + +### Improvements + +- Added a Product Hunt badge and a link to the changelog on the README. + ## [0.1.31] - 2026-09-12 ### New Features diff --git a/package-lock.json b/package-lock.json index f66b368..528bace 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18355,7 +18355,7 @@ }, "packages/cli": { "name": "@cortex-docs/cli", - "version": "0.1.31", + "version": "0.1.32", "bundleDependencies": [ "@cortex-docs/core", "@cortex-docs/codegen", @@ -18549,9 +18549,9 @@ }, "packages/docs-site": { "name": "@cortex-docs/docs-site", - "version": "0.1.31", + "version": "0.1.32", "dependencies": { - "@cortex-docs/cli": "0.1.31" + "@cortex-docs/cli": "0.1.32" } }, "packages/docs-ui": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 35baccb..eac040c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@cortex-docs/cli", - "version": "0.1.31", + "version": "0.1.32", "description": "Cortex Docs CLI for SDKs, API documentation, and MCP servers", "license": "MIT", "repository": { diff --git a/packages/docs-site/package.json b/packages/docs-site/package.json index 8e08dad..9fcc601 100644 --- a/packages/docs-site/package.json +++ b/packages/docs-site/package.json @@ -1,6 +1,6 @@ { "name": "@cortex-docs/docs-site", - "version": "0.1.31", + "version": "0.1.32", "private": true, "description": "Product documentation for Cortex Docs", "scripts": { @@ -9,6 +9,6 @@ "clean": "rm -rf .cortex" }, "dependencies": { - "@cortex-docs/cli": "0.1.31" + "@cortex-docs/cli": "0.1.32" } } From dec9c63ac467ef061bb64bcb8f3d4ab6166a49d5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:27:23 +0000 Subject: [PATCH 3/9] chore(release): v0.1.33 --- CHANGELOG.md | 15 +++++++++++++++ package-lock.json | 6 +++--- packages/cli/package.json | 2 +- packages/docs-site/package.json | 4 ++-- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f311885..ad67bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,21 @@ The project uses Semantic Versioning. Each release contains the same three chang - None. +## [0.1.33] - 2026-09-22 + +### New Features + +- None. + +### Bug Fixes + +- None. + +### Improvements + +- Replaced the static SVG overview in the README with an animated terminal demo GIF and PNG for a more interactive preview of Cortex. +- Added a Product Hunt badge and a changelog link to the README. + ## [0.1.32] - 2026-09-13 ### New Features diff --git a/package-lock.json b/package-lock.json index 528bace..a7b8f2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18355,7 +18355,7 @@ }, "packages/cli": { "name": "@cortex-docs/cli", - "version": "0.1.32", + "version": "0.1.33", "bundleDependencies": [ "@cortex-docs/core", "@cortex-docs/codegen", @@ -18549,9 +18549,9 @@ }, "packages/docs-site": { "name": "@cortex-docs/docs-site", - "version": "0.1.32", + "version": "0.1.33", "dependencies": { - "@cortex-docs/cli": "0.1.32" + "@cortex-docs/cli": "0.1.33" } }, "packages/docs-ui": { diff --git a/packages/cli/package.json b/packages/cli/package.json index eac040c..8ae67e8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@cortex-docs/cli", - "version": "0.1.32", + "version": "0.1.33", "description": "Cortex Docs CLI for SDKs, API documentation, and MCP servers", "license": "MIT", "repository": { diff --git a/packages/docs-site/package.json b/packages/docs-site/package.json index 9fcc601..345212b 100644 --- a/packages/docs-site/package.json +++ b/packages/docs-site/package.json @@ -1,6 +1,6 @@ { "name": "@cortex-docs/docs-site", - "version": "0.1.32", + "version": "0.1.33", "private": true, "description": "Product documentation for Cortex Docs", "scripts": { @@ -9,6 +9,6 @@ "clean": "rm -rf .cortex" }, "dependencies": { - "@cortex-docs/cli": "0.1.32" + "@cortex-docs/cli": "0.1.33" } } From e79f2efd845823a6acbeb902ee287507be0ad2ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:46:08 +0000 Subject: [PATCH 4/9] chore(release): v0.1.34 --- CHANGELOG.md | 14 ++++++++++++++ package-lock.json | 6 +++--- packages/cli/package.json | 2 +- packages/docs-site/package.json | 4 ++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad67bb2..91ee944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,20 @@ The project uses Semantic Versioning. Each release contains the same three chang - None. +## [0.1.34] - 2026-09-22 + +### New Features + +- None. + +### Bug Fixes + +- None. + +### Improvements + +- Sharpened the README demo animation and poster image for better clarity. + ## [0.1.33] - 2026-09-22 ### New Features diff --git a/package-lock.json b/package-lock.json index a7b8f2b..9c8e208 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18355,7 +18355,7 @@ }, "packages/cli": { "name": "@cortex-docs/cli", - "version": "0.1.33", + "version": "0.1.34", "bundleDependencies": [ "@cortex-docs/core", "@cortex-docs/codegen", @@ -18549,9 +18549,9 @@ }, "packages/docs-site": { "name": "@cortex-docs/docs-site", - "version": "0.1.33", + "version": "0.1.34", "dependencies": { - "@cortex-docs/cli": "0.1.33" + "@cortex-docs/cli": "0.1.34" } }, "packages/docs-ui": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 8ae67e8..c19b9d3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@cortex-docs/cli", - "version": "0.1.33", + "version": "0.1.34", "description": "Cortex Docs CLI for SDKs, API documentation, and MCP servers", "license": "MIT", "repository": { diff --git a/packages/docs-site/package.json b/packages/docs-site/package.json index 345212b..dcf8127 100644 --- a/packages/docs-site/package.json +++ b/packages/docs-site/package.json @@ -1,6 +1,6 @@ { "name": "@cortex-docs/docs-site", - "version": "0.1.33", + "version": "0.1.34", "private": true, "description": "Product documentation for Cortex Docs", "scripts": { @@ -9,6 +9,6 @@ "clean": "rm -rf .cortex" }, "dependencies": { - "@cortex-docs/cli": "0.1.33" + "@cortex-docs/cli": "0.1.34" } } From 0aa5e33cddc440e8ad7efa2de2bf9fd9e8fc8ce9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:46:32 +0000 Subject: [PATCH 5/9] chore(release): v0.1.35 --- CHANGELOG.md | 14 ++++++++++++++ package-lock.json | 6 +++--- packages/cli/package.json | 2 +- packages/docs-site/package.json | 4 ++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91ee944..c25775d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,20 @@ The project uses Semantic Versioning. Each release contains the same three chang - None. +## [0.1.35] - 2026-09-22 + +### New Features + +- None. + +### Bug Fixes + +- None. + +### Improvements + +- Documented the `primaryColor` configuration option in the README, showing how to set the documentation accent color as a six-digit hex value. + ## [0.1.34] - 2026-09-22 ### New Features diff --git a/package-lock.json b/package-lock.json index 9c8e208..60abd02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18355,7 +18355,7 @@ }, "packages/cli": { "name": "@cortex-docs/cli", - "version": "0.1.34", + "version": "0.1.35", "bundleDependencies": [ "@cortex-docs/core", "@cortex-docs/codegen", @@ -18549,9 +18549,9 @@ }, "packages/docs-site": { "name": "@cortex-docs/docs-site", - "version": "0.1.34", + "version": "0.1.35", "dependencies": { - "@cortex-docs/cli": "0.1.34" + "@cortex-docs/cli": "0.1.35" } }, "packages/docs-ui": { diff --git a/packages/cli/package.json b/packages/cli/package.json index c19b9d3..aa235d4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@cortex-docs/cli", - "version": "0.1.34", + "version": "0.1.35", "description": "Cortex Docs CLI for SDKs, API documentation, and MCP servers", "license": "MIT", "repository": { diff --git a/packages/docs-site/package.json b/packages/docs-site/package.json index dcf8127..84837f8 100644 --- a/packages/docs-site/package.json +++ b/packages/docs-site/package.json @@ -1,6 +1,6 @@ { "name": "@cortex-docs/docs-site", - "version": "0.1.34", + "version": "0.1.35", "private": true, "description": "Product documentation for Cortex Docs", "scripts": { @@ -9,6 +9,6 @@ "clean": "rm -rf .cortex" }, "dependencies": { - "@cortex-docs/cli": "0.1.34" + "@cortex-docs/cli": "0.1.35" } } From 3e6f33f3d7bfa76a4d5f5dffd23f3652e074f6fb Mon Sep 17 00:00:00 2001 From: Nick Chisiu <8492343+nickchisiu@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:08:21 +0300 Subject: [PATCH 6/9] feat: deploy public repository docs and serve live MCP --- README.md | 35 +- package-lock.json | 237 ++++++++---- packages/cli/README.md | 24 ++ packages/cli/__tests__/docs-build.test.ts | 34 ++ .../cli/__tests__/repository-hosting.test.ts | 161 ++++++++ packages/cli/package.json | 1 + packages/cli/src/app.module.ts | 4 + .../cli/src/commands/deploy/deploy.command.ts | 215 +++++++++++ .../cli/src/commands/docs/build.command.ts | 43 ++- .../cli/src/commands/mcp/mcp-serve.command.ts | 16 + .../cli/src/commands/mcp/repository-server.ts | 178 +++++++++ packages/cli/src/main.ts | 2 +- packages/cli/src/services/repository-docs.ts | 136 +++++++ packages/core/__tests__/hosting.test.ts | 137 +++++++ packages/core/package.json | 11 + packages/core/src/config/schema.ts | 4 + packages/core/src/config/types.ts | 1 + packages/core/src/hosting.ts | 358 ++++++++++++++++++ packages/docs-site/cortex.config.yml | 7 + packages/docs-site/docs/configuration.md | 15 + packages/docs-site/docs/mcp-servers.md | 6 + .../docs-site/docs/open-source-hosting.md | 146 +++++++ packages/docs-ui/app/api/asyncapi/route.ts | 2 + packages/docs-ui/app/api/config/route.ts | 5 +- packages/docs-ui/app/api/mcp/route.ts | 76 ++-- packages/docs-ui/app/api/sdk-readme/route.ts | 17 +- .../docs-ui/app/api/sdk-snippets/route.ts | 2 +- packages/docs-ui/app/api/spec/route.ts | 1 + packages/docs-ui/app/layout.tsx | 5 +- packages/docs-ui/app/page.tsx | 22 +- .../__tests__/repository-setup.test.ts | 28 ++ packages/mcp-gen/src/index.ts | 1 + packages/mcp-gen/src/repository-setup.ts | 75 ++++ 33 files changed, 1869 insertions(+), 136 deletions(-) create mode 100644 packages/cli/__tests__/repository-hosting.test.ts create mode 100644 packages/cli/src/commands/deploy/deploy.command.ts create mode 100644 packages/cli/src/commands/mcp/mcp-serve.command.ts create mode 100644 packages/cli/src/commands/mcp/repository-server.ts create mode 100644 packages/cli/src/services/repository-docs.ts create mode 100644 packages/core/__tests__/hosting.test.ts create mode 100644 packages/core/src/hosting.ts create mode 100644 packages/docs-site/docs/open-source-hosting.md create mode 100644 packages/mcp-gen/__tests__/repository-setup.test.ts create mode 100644 packages/mcp-gen/src/repository-setup.ts diff --git a/README.md b/README.md index 26fe4ec..855ff11 100644 --- a/README.md +++ b/README.md @@ -51,43 +51,32 @@ If Cortex helps your team, [star this repository](https://github.com/cortex-docs ## Try Cortex in 60 seconds -Create a sample project and inspect the generation plan: +Start a local demo: ```bash mkdir petstore cd petstore npm install --global @cortex-docs/cli cortex init petstore -cortex validate -cortex generate --dry-run +cortex docs serve ``` -Cortex validates each source and shows every planned output: - -```text -✓ Config is valid -✓ Parsed AsyncAPI: WebSocket API -✓ Parsed GraphQL: GraphQL -✓ Parsed OpenRPC: OpenRPC -✓ Parsed OpenAPI: REST API V1 -Languages: typescript, python, go, java, kotlin, ruby, php, csharp, rust, cpp, c - -typescript [REST + WS + GraphQL + OpenRPC] → generated/typescript/petstore-typescript-client-sdk -python [REST + WS + GraphQL + OpenRPC] → generated/python/petstore-python-sdk -... -mcp-server → generated/mcp-server -``` +Open `http://localhost:3012`. Press `Ctrl+C` to stop the server. -The generated MCP server gives AI agents typed tools, specifications, SDK guides, and project documentation. +## We love open source -Generate the files. Then start the local documentation preview: +Keep your Markdown in Git. Add `cortex.config.yml` to the default branch of your public GitHub repository, then run: ```bash -cortex generate -cortex docs serve +npx -y @cortex-docs/cli deploy https://github.com/OWNER/REPO +npx -y @cortex-docs/cli mcp-serve https://github.com/OWNER/REPO ``` -Open `http://localhost:3012`. Press `Ctrl+C` to stop the server. +The first command publishes a free documentation site at `PROJECT.cortexdocs.dev`. The second connects AI clients to current documentation through local MCP. + +Deploy requires repository write access. MCP works with public read access and checks for changes before each request. + +See the [open source hosting guide](packages/docs-site/docs/open-source-hosting.md) for configuration, custom domains, automatic updates, and limits. ## Features diff --git a/package-lock.json b/package-lock.json index cf85a54..9dc1209 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2936,6 +2936,18 @@ "vue": "^3.2.0" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3750,6 +3762,46 @@ "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.1.tgz", + "integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@nestjs/common": { "version": "11.2.3", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.3.tgz", @@ -9052,7 +9104,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -9146,6 +9197,23 @@ } } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -9370,7 +9438,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -9395,7 +9462,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -9470,7 +9536,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -9499,7 +9564,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9513,7 +9577,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -9864,7 +9927,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -9878,7 +9940,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9907,7 +9968,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9917,7 +9977,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.6.0" @@ -9927,7 +9986,6 @@ "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, "license": "MIT", "dependencies": { "object-assign": "^4", @@ -10008,7 +10066,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -10284,7 +10341,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -10411,7 +10467,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -10451,7 +10506,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, "license": "MIT" }, "node_modules/embla-carousel": { @@ -10501,7 +10555,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -10569,7 +10622,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10579,7 +10631,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10596,7 +10647,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -10687,7 +10737,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, "license": "MIT" }, "node_modules/escape-string-regexp": { @@ -10931,7 +10980,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -10953,6 +11001,18 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/eventsource-parser": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", @@ -11000,7 +11060,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -11040,6 +11099,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -11156,7 +11234,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -11331,7 +11408,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -11341,7 +11417,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -11373,7 +11448,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11439,7 +11513,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -11485,7 +11558,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -11568,7 +11640,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11672,7 +11743,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11701,7 +11771,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -12030,6 +12099,15 @@ "node": ">=12.0.0" } }, + "node_modules/hono": { + "version": "4.13.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.9.tgz", + "integrity": "sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hookable": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz", @@ -12098,7 +12176,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -12359,11 +12436,19 @@ "node": ">=12" } }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.10" @@ -12497,7 +12582,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, "license": "MIT" }, "node_modules/is-regexp": { @@ -12564,7 +12648,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -12640,6 +12723,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-base64": { "version": "3.9.3", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.3.tgz", @@ -12699,6 +12791,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -13226,7 +13324,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13458,7 +13555,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -13468,7 +13564,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -14057,7 +14152,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -14067,7 +14161,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -14237,7 +14330,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "dev": true, "license": "MIT", "dependencies": { "content-type": "^2.1.0" @@ -14254,7 +14346,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -14441,7 +14532,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14451,7 +14541,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14495,7 +14584,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -14508,7 +14596,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -14841,7 +14928,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -14861,7 +14947,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14928,6 +15013,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/playwright": { "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", @@ -15091,7 +15185,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -15115,7 +15208,6 @@ "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -15265,7 +15357,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -15275,7 +15366,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -15840,7 +15930,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -16051,7 +16140,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -16078,7 +16166,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -16122,7 +16209,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, "license": "ISC" }, "node_modules/sha.js": { @@ -16200,7 +16286,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -16213,7 +16298,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16242,7 +16326,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -16262,7 +16345,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -16279,7 +16361,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -16298,7 +16379,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -16394,7 +16474,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -16760,7 +16839,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -16917,7 +16995,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "dev": true, "license": "MIT", "dependencies": { "content-type": "^2.0.0", @@ -16936,7 +17013,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -17345,7 +17421,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -17430,7 +17505,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -18048,7 +18122,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -18191,7 +18264,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -18343,6 +18415,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -18368,6 +18449,7 @@ "@cortex-docs/core": "0.1.6", "@cortex-docs/docs-ui": "0.1.6", "@cortex-docs/mcp-gen": "0.1.6", + "@modelcontextprotocol/sdk": "^1.30.1", "@nestjs/common": "^11.2.3", "@nestjs/core": "^11.2.3", "chalk": "^6.0.0", @@ -18674,6 +18756,21 @@ "js-yaml": "bin/js-yaml.mjs" } }, + "packages/hosting-worker": { + "name": "@cortex-docs/hosting-worker", + "version": "0.0.0", + "extraneous": true, + "dependencies": { + "@cortex-docs/core": "0.1.6" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260828.1", + "miniflare": "^5.20260911.0-alpha", + "typescript": "^5.9.3", + "vitest": "^4.1.10", + "wrangler": "^4.127.0" + } + }, "packages/mcp-gen": { "name": "@cortex-docs/mcp-gen", "version": "0.1.6", diff --git a/packages/cli/README.md b/packages/cli/README.md index db6715c..0271ce8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -26,4 +26,28 @@ cortex docs build --output .cortex/docs Deploy the output directory to a static web host. Configure page URLs such as `/docs/quickstart` to resolve to `/docs/quickstart.html`. The deployed site needs no Node.js server. Rebuild the site after source changes. +## Public repository hosting + +Commit `cortex.config.yml` and your Markdown to the default branch of a public GitHub repository. Then run: + +```bash +cortex deploy https://github.com/OWNER/REPO +``` + +The `project` configuration value becomes `PROJECT.cortexdocs.dev`. Deploy requires `gh auth login` or `GITHUB_TOKEN` with repository write access. + +If documentation inputs are unchanged, deployment skips the build and upload. Use `deploy.domain` for a verified custom domain. + +## Local repository MCP + +```bash +cortex mcp-serve https://github.com/OWNER/REPO +``` + +This stdio server checks the default branch before each documentation request. Configure your MCP client to run the command as a local process. + +The existing `cortex mcp generate` and npm publishing commands remain available. + +Read the [hosting guide](https://github.com/cortex-docs/cortex/blob/main/packages/docs-site/docs/open-source-hosting.md) for client setup, automatic deployment, and limits. + Read the [Cortex Docs repository](https://github.com/cortex-docs/cortex) for the complete documentation. diff --git a/packages/cli/__tests__/docs-build.test.ts b/packages/cli/__tests__/docs-build.test.ts index 4edec19..5bc0ed3 100644 --- a/packages/cli/__tests__/docs-build.test.ts +++ b/packages/cli/__tests__/docs-build.test.ts @@ -37,9 +37,43 @@ describe('docs build', () => { }); afterEach(() => { + vi.unstubAllEnvs(); fs.rmSync(workspace, { recursive: true, force: true }); }); + it('isolates hosted builds from inherited configuration and omits empty dynamic routes', async () => { + vi.stubEnv('CORTEX_SPEC_PATH', '/unrelated/private-spec.yaml'); + vi.stubEnv('CORTEX_TEMPLATE_ROOT', '/unrelated/templates'); + vi.mocked(prepareDocsUiBuildRuntime).mockImplementationOnce((_source, runtime) => { + for (const route of [ + 'api-reference/[...slug]', + 'sdks/[language]', + 'docs/[slug]', + 'mcp/[tool]', + 'assets/[...path]', + ]) + fs.mkdirSync(path.join(runtime, 'app', route), { recursive: true }); + }); + vi.mocked(execFileSync).mockImplementation((_file, _args, options) => { + expect(options!.env!.CORTEX_SPEC_PATH).toBeUndefined(); + expect(options!.env!.CORTEX_TEMPLATE_ROOT).toBeUndefined(); + expect(options!.env!.CORTEX_HOSTED_REPOSITORY).toBe('https://github.com/owner/repo'); + const runtime = String(options!.cwd); + for (const route of [ + 'api-reference/[...slug]', + 'sdks/[language]', + 'docs/[slug]', + 'mcp/[tool]', + 'assets/[...path]', + ]) + expect(fs.existsSync(path.join(runtime, 'app', route))).toBe(false); + fs.mkdirSync(path.join(runtime, 'out')); + fs.writeFileSync(path.join(runtime, 'out/index.html'), 'docs'); + return Buffer.from(''); + }); + await command.run([], { output: outputDir, repository: 'https://github.com/owner/repo' }); + }); + it('replaces the previous build with exported pages, data, and browser assets', async () => { vi.mocked(execFileSync).mockImplementation((_file, _args, options) => { const runtimeDir = String(options!.cwd); diff --git a/packages/cli/__tests__/repository-hosting.test.ts b/packages/cli/__tests__/repository-hosting.test.ts new file mode 100644 index 0000000..3820a2d --- /dev/null +++ b/packages/cli/__tests__/repository-hosting.test.ts @@ -0,0 +1,161 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; +import { readRepositorySnapshot, type RepositorySnapshot } from '@cortex-docs/core/hosting'; +import { materializeRepository } from '../src/services/repository-docs'; +import { DeployCommand } from '../src/commands/deploy/deploy.command'; +import { + RepositoryDocumentation, + createRepositoryServer, +} from '../src/commands/mcp/repository-server'; +import type { DocsBuildCommand } from '../src/commands/docs/build.command'; +import type { LoggerService } from '../src/services/logger.service'; + +vi.mock('@cortex-docs/core/hosting', async (original) => ({ + ...(await original()), + readRepositorySnapshot: vi.fn(), +})); +vi.mock('../src/services/repository-docs', async (original) => ({ + ...(await original()), + materializeRepository: vi.fn(), +})); + +let snapshot: RepositorySnapshot; +let markdown: string; +let directory: string; +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('GITHUB_TOKEN', 'test-maintainer'); + markdown = '# Current documentation'; + snapshot = { + repository: 'https://github.com/owner/repo', + repositoryId: 42, + branch: 'main', + commit: 'first', + fingerprint: 'one', + configText: + 'project: example\ndocs:\n - section: Guides\n sources:\n - title: Guide\n document: README.md\n', + config: { + project: 'example', + sources: [], + languages: [], + output: { base_dir: './generated' }, + docs: [{ section: 'Guides', sources: [{ title: 'Guide', document: 'README.md' }] }], + }, + files: [ + { path: 'cortex.config.yml', sha: 'config', size: 1 }, + { path: 'README.md', sha: 'doc', size: 1 }, + ], + }; + vi.mocked(readRepositorySnapshot).mockImplementation(async () => snapshot); + vi.mocked(materializeRepository).mockImplementation(async (current, target) => { + directory = target; + fs.writeFileSync(path.join(target, 'cortex.config.yml'), current.configText); + fs.writeFileSync(path.join(target, 'README.md'), markdown); + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('repository deployments', () => { + const logger = { header: vi.fn(), info: vi.fn(), success: vi.fn() } as unknown as LoggerService; + it('skips materializing, building and uploading when inputs are unchanged', async () => { + const build = { run: vi.fn() }; + const fetcher = vi.fn(async (url: string) => + Response.json( + url.endsWith('/domain') + ? { status: 'not_configured' } + : { status: 'unchanged', url: 'https://example.cortexdocs.dev' }, + ), + ); + vi.stubGlobal('fetch', fetcher); + await new DeployCommand(logger, build as unknown as DocsBuildCommand).run(['owner/repo']); + expect(build.run).not.toHaveBeenCalled(); + expect(materializeRepository).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it('cancels the upload lease and removes temporary files after an upload fails', async () => { + const build = { + run: vi.fn(async (_args, options) => { + fs.mkdirSync(options.output); + fs.writeFileSync(path.join(options.output, 'index.html'), '

Docs

'); + }), + }; + const fetcher = vi.fn(async (_url: string, init: RequestInit) => { + if (init.method === 'DELETE') return Response.json({ status: 'cancelled' }); + if (init.method === 'PUT') return Response.json({ error: 'upload failed' }, { status: 503 }); + const body = JSON.parse(String(init.body)); + return Response.json( + body.files + ? { status: 'upload_required', deploymentId: 'test', uploadToken: 'upload-token' } + : { status: 'build_required' }, + ); + }); + vi.stubGlobal('fetch', fetcher); + await expect( + new DeployCommand(logger, build as unknown as DocsBuildCommand).run(['owner/repo']), + ).rejects.toThrow('upload failed'); + expect(fetcher.mock.calls.some(([_url, init]) => init.method === 'DELETE')).toBe(true); + expect(fs.existsSync(directory)).toBe(false); + }); +}); + +describe('live repository MCP', () => { + it('refreshes Markdown and tool lists over a real MCP transport, and rejects stale reads', async () => { + const documentation = new RepositoryDocumentation('owner/repo'); + const server = createRepositoryServer(documentation); + const client = new Client({ name: 'test', version: '1.0.0' }); + const changed = vi.fn(); + client.setNotificationHandler(ToolListChangedNotificationSchema, changed); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + const listed = await client.listTools(); + const guide = listed.tools.find((tool) => tool.name.includes('guide'))!; + expect(guide).toBeDefined(); + expect(await client.callTool({ name: guide.name })).toMatchObject({ + content: [{ type: 'text', text: markdown }], + }); + expect(materializeRepository).toHaveBeenCalledTimes(1); + markdown = '# Changed documentation'; + snapshot = { ...snapshot, commit: 'second', fingerprint: 'two' }; + expect(await client.callTool({ name: guide.name })).toMatchObject({ + content: [{ text: markdown }], + }); + expect(changed).toHaveBeenCalled(); + const resources = await client.listResources(); + expect( + await client.readResource({ + uri: resources.resources.find((r) => r.name === 'README.md')!.uri, + }), + ).toMatchObject({ contents: [{ text: markdown }] }); + snapshot = { + ...snapshot, + commit: 'third', + fingerprint: 'three', + config: { ...snapshot.config, docs: [] }, + }; + expect((await client.callTool({ name: guide.name })).isError).toBe(true); + vi.mocked(readRepositorySnapshot).mockRejectedValueOnce(new Error('GitHub unavailable')); + const failed = await client.callTool({ name: guide.name }); + expect(failed.isError).toBe(true); + expect(JSON.stringify(failed)).toContain('GitHub unavailable'); + expect(fs.existsSync(directory)).toBe(false); + } finally { + await client.close(); + await server.close(); + } + }); + it('coalesces concurrent checks', async () => { + const documentation = new RepositoryDocumentation('owner/repo'); + const [first, second] = await Promise.all([documentation.refresh(), documentation.refresh()]); + expect(first).toBe(second); + expect(readRepositorySnapshot).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index baed299..b428b48 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,6 +49,7 @@ "@cortex-docs/core": "0.1.6", "@cortex-docs/docs-ui": "0.1.6", "@cortex-docs/mcp-gen": "0.1.6", + "@modelcontextprotocol/sdk": "^1.30.1", "@nestjs/common": "^11.2.3", "@nestjs/core": "^11.2.3", "chalk": "^6.0.0", diff --git a/packages/cli/src/app.module.ts b/packages/cli/src/app.module.ts index 3a0982a..e610031 100644 --- a/packages/cli/src/app.module.ts +++ b/packages/cli/src/app.module.ts @@ -12,10 +12,14 @@ import { GeneratorsCommand } from './commands/generators/generators.command'; import { GeneratorsExportCommand } from './commands/generators/export.command'; import { ProjectService } from './services/project.service'; import { LoggerService } from './services/logger.service'; +import { DeployCommand } from './commands/deploy/deploy.command'; +import { McpServeCommand } from './commands/mcp/mcp-serve.command'; @Module({ providers: [ InitCommand, + DeployCommand, + McpServeCommand, GenerateCommand, ValidateCommand, DocsCommand, diff --git a/packages/cli/src/commands/deploy/deploy.command.ts b/packages/cli/src/commands/deploy/deploy.command.ts new file mode 100644 index 0000000..d916604 --- /dev/null +++ b/packages/cli/src/commands/deploy/deploy.command.ts @@ -0,0 +1,215 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { Command, CommandRunner } from 'nest-commander'; +import { + assertProjectName, + HOSTING_API, + parseRepository, + readRepositorySnapshot, + sha256, + validateManifest, + type UploadFile, +} from '@cortex-docs/core/hosting'; +import { DocsBuildCommand } from '../docs/build.command'; +import { LoggerService } from '../../services/logger.service'; +import { + materializeRepository, + assertLocalSpecReferences, + rewriteRepositoryMarkdown, + mapConcurrent, +} from '../../services/repository-docs'; + +export function githubToken(): string | undefined { + const fromEnvironment = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (fromEnvironment) return fromEnvironment; + try { + return ( + execFileSync('gh', ['auth', 'token', '--hostname', 'github.com'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }).trim() || undefined + ); + } catch { + return undefined; + } +} + +export function hostingApi(): string { + const url = new URL(process.env.CORTEX_HOSTING_API ?? HOSTING_API); + if ( + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash || + (url.protocol !== 'https:' && + !(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname))) + ) + throw new Error( + 'CORTEX_HOSTING_API must be an HTTPS origin (HTTP is allowed only on localhost).', + ); + return url.origin; +} + +export async function deploymentRequest( + api: string, + route: string, + token: string, + init: RequestInit = {}, +): Promise> { + const response = await fetch(`${api}${route}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...init.headers, + }, + redirect: 'error', + signal: AbortSignal.timeout(60_000), + }); + let data: Record; + try { + data = (await response.json()) as Record; + } catch { + throw new Error(`Hosting service returned HTTP ${response.status}.`); + } + if (!response.ok) + throw new Error( + typeof data.error === 'string' ? data.error : `Hosting request failed (${response.status}).`, + ); + return data; +} + +export async function buildManifest(directory: string): Promise { + const files: UploadFile[] = []; + async function walk(relative: string): Promise { + for (const entry of fs.readdirSync(path.join(directory, relative), { withFileTypes: true })) { + const filename = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) throw new Error(`Static output contains a symlink: ${filename}`); + if (entry.isDirectory()) await walk(filename); + else { + const bytes = fs.readFileSync(path.join(directory, filename)); + files.push({ path: filename, size: bytes.byteLength, sha256: await sha256(bytes) }); + } + } + } + await walk(''); + return validateManifest(files.sort((a, b) => a.path.localeCompare(b.path))); +} + +@Command({ + name: 'deploy', + arguments: '[repository]', + description: + 'Deploy default-branch documentation from a public GitHub repository to cortexdocs.dev', +}) +export class DeployCommand extends CommandRunner { + constructor( + private readonly logger: LoggerService, + private readonly build: DocsBuildCommand, + ) { + super(); + } + + async run(params: string[]): Promise { + this.logger.header('Cortex Open Source Hosting'); + const input = + params[0] ?? + execFileSync('git', ['remote', 'get-url', 'origin'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + const repository = parseRepository(input).url; + const token = githubToken(); + if (!token) + throw new Error( + 'Sign in with gh auth login, or set GITHUB_TOKEN with write access to this public repository.', + ); + const api = hostingApi(); + const snapshot = await readRepositorySnapshot(repository, { token, requirePush: true }); + assertProjectName(snapshot.config.project); + this.logger.info( + `Repository: ${repository} (${snapshot.branch} @ ${snapshot.commit.slice(0, 8)})`, + ); + const request = { repository, fingerprint: snapshot.fingerprint }; + let result = await deploymentRequest(api, '/v1/deployments', token, { + method: 'POST', + body: JSON.stringify(request), + }); + if (result.status !== 'unchanged') { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cortex-deploy-')); + let pending: { id: string; token: string } | undefined; + try { + await materializeRepository(snapshot, directory); + assertLocalSpecReferences(snapshot, directory); + rewriteRepositoryMarkdown(snapshot, directory); + const output = path.join(directory, '.site'); + await this.build.run([], { + output, + config: path.join(directory, 'cortex.config.yml'), + repository, + }); + const files = await buildManifest(output); + result = await deploymentRequest(api, '/v1/deployments', token, { + method: 'POST', + body: JSON.stringify({ ...request, files }), + }); + if (result.status !== 'unchanged') { + if (typeof result.deploymentId !== 'string' || typeof result.uploadToken !== 'string') + throw new Error('Hosting service returned an invalid upload session.'); + pending = { id: result.deploymentId, token: result.uploadToken }; + const session = pending; + this.logger.info(`Uploading ${files.length} files...`); + await mapConcurrent(files, 4, async (file) => { + await deploymentRequest( + api, + `/v1/deployments/${session.id}/files/${encodeURIComponent(file.path)}`, + session.token, + { + method: 'PUT', + headers: { 'Content-Type': 'application/octet-stream' }, + body: fs.readFileSync(path.join(output, file.path)), + }, + ); + }); + result = await deploymentRequest(api, `/v1/deployments/${session.id}/finalize`, token, { + method: 'POST', + }); + pending = undefined; + } + } finally { + if (pending) + await deploymentRequest(api, `/v1/deployments/${pending.id}`, pending.token, { + method: 'DELETE', + }).catch(() => {}); + fs.rmSync(directory, { recursive: true, force: true }); + } + } + this.logger.success( + `${result.status === 'unchanged' ? 'Unchanged — skipped build and upload' : 'Deployed'}: ${result.url}`, + ); + this.logger.info('Updates become visible across the edge within 30 seconds.'); + const domain = await deploymentRequest( + api, + `/v1/projects/${snapshot.config.project}/domain`, + token, + { method: 'POST' }, + ); + if (domain.status !== 'not_configured') { + this.logger.info(`Custom domain: ${domain.hostname} (${domain.status})`); + if (domain.status !== 'active') { + for (const record of domain.records ?? []) + this.logger.info(`${record.type} ${record.name} → ${record.value}`); + if (domain.validationRecords) + this.logger.info(`Certificate validation: ${JSON.stringify(domain.validationRecords)}`); + if (domain.ownershipVerification) + this.logger.info(`Hostname validation: ${JSON.stringify(domain.ownershipVerification)}`); + this.logger.info('Add the DNS records, then rerun cortex deploy to check activation.'); + } + } + this.logger.info(`Local MCP: npx -y @cortex-docs/cli mcp-serve ${repository}`); + } +} diff --git a/packages/cli/src/commands/docs/build.command.ts b/packages/cli/src/commands/docs/build.command.ts index badc2ee..8e290f2 100644 --- a/packages/cli/src/commands/docs/build.command.ts +++ b/packages/cli/src/commands/docs/build.command.ts @@ -24,10 +24,13 @@ export class DocsBuildCommand extends CommandRunner { super(); } - async run(params: string[], options: { spec?: string; output?: string }): Promise { + async run( + params: string[], + options: { spec?: string; output?: string; config?: string; repository?: string }, + ): Promise { this.logger.header('Cortex Docs Build'); - const foundConfigPath = await this.project.findConfig(); + const foundConfigPath = options.config ?? (await this.project.findConfig()); const configPath = foundConfigPath ? path.resolve(foundConfigPath) : undefined; const config = await this.project.loadConfig(configPath); const templateRoot = resolveGeneratorTemplateRoot(config, configPath); @@ -63,7 +66,21 @@ export class DocsBuildCommand extends CommandRunner { CORTEX_STATIC_EXPORT: '1', CORTEX_CLOUDFLARE: '0', }; + if (options.repository) + for (const key of Object.keys(env)) { + if ( + key.startsWith('CORTEX_') && + ![ + 'CORTEX_DIST_DIR', + 'CORTEX_DOCS_UI_ROOT', + 'CORTEX_STATIC_EXPORT', + 'CORTEX_CLOUDFLARE', + ].includes(key) + ) + delete env[key]; + } if (configPath) env.CORTEX_CONFIG_PATH = configPath; + if (options.repository) env.CORTEX_HOSTED_REPOSITORY = options.repository; if (specPath) env.CORTEX_SPEC_PATH = specPath; const asyncApiPath = getFirstSpecPath(config, 'asyncapi-spec'); if (asyncApiPath) env.CORTEX_ASYNCAPI_PATH = asyncApiPath; @@ -83,6 +100,28 @@ export class DocsBuildCommand extends CommandRunner { this.logger.info('Building static docs...'); try { prepareDocsUiBuildRuntime(docsUiPath, runtimeDir); + if (env.CORTEX_HOSTED_REPOSITORY) { + // Next's static exporter requires at least one parameter per dynamic route. + // Omit unused routes from this isolated build for Markdown-only projects. + const removeRoute = (route: string) => + fs.rmSync(path.join(runtimeDir, 'app', route), { recursive: true, force: true }); + const hasDocs = config.docs?.some((section) => section.sources.length > 0); + if (!config.sources.length) { + removeRoute('api-reference/[...slug]'); + removeRoute('sdks/[language]'); + } + if (!hasDocs) removeRoute('docs/[slug]'); + if (!hasDocs && !config.sources.some((source) => source.intro)) removeRoute('mcp/[tool]'); + const assets = configPath ? path.join(path.dirname(configPath), 'assets') : ''; + if ( + !assets || + !fs.existsSync(assets) || + !fs + .readdirSync(assets, { recursive: true, withFileTypes: true }) + .some((entry) => entry.isFile()) + ) + removeRoute('assets/[...path]'); + } execFileSync(process.execPath, [nextBin, 'build', '--webpack'], { cwd: runtimeDir, env, diff --git a/packages/cli/src/commands/mcp/mcp-serve.command.ts b/packages/cli/src/commands/mcp/mcp-serve.command.ts new file mode 100644 index 0000000..a3ee2f7 --- /dev/null +++ b/packages/cli/src/commands/mcp/mcp-serve.command.ts @@ -0,0 +1,16 @@ +import { Command, CommandRunner } from 'nest-commander'; +import { parseRepository } from '@cortex-docs/core/hosting'; +import { githubToken } from '../deploy/deploy.command'; +import { serveRepository } from './repository-server'; + +@Command({ + name: 'mcp-serve', + arguments: '', + description: + 'Run a local stdio MCP server with current Markdown documentation from a public GitHub repository', +}) +export class McpServeCommand extends CommandRunner { + async run(params: string[]): Promise { + await serveRepository(parseRepository(params[0]).url, githubToken()); + } +} diff --git a/packages/cli/src/commands/mcp/repository-server.ts b/packages/cli/src/commands/mcp/repository-server.ts new file mode 100644 index 0000000..dc724f2 --- /dev/null +++ b/packages/cli/src/commands/mcp/repository-server.ts @@ -0,0 +1,178 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { + readRepositorySnapshot, + repositoryPath, + type RepositorySnapshot, +} from '@cortex-docs/core/hosting'; +import { buildConfigToolDefinitions, type McpStaticTool } from '@cortex-docs/mcp-gen'; +import { materializeRepository } from '../../services/repository-docs'; + +interface RepositoryResource { + name: string; + uri: string; + mimeType: string; + text: string; +} +export interface DocumentationState { + snapshot: RepositorySnapshot; + tools: McpStaticTool[]; + resources: RepositoryResource[]; +} + +export class RepositoryDocumentation { + private current?: DocumentationState; + private pending?: Promise; + constructor( + private readonly repository: string, + private readonly token?: string, + ) {} + + refresh(): Promise { + if (!this.pending) + this.pending = this.read().finally(() => { + this.pending = undefined; + }); + return this.pending; + } + + private async read(): Promise { + const snapshot = await readRepositorySnapshot(this.repository, { + token: this.token, + previous: this.current?.snapshot, + }); + if (this.current?.snapshot.fingerprint === snapshot.fingerprint) { + this.current = { ...this.current, snapshot }; + return this.current; + } + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cortex-mcp-')); + try { + const paths = new Set([ + 'cortex.config.yml', + ...(snapshot.config.docs?.flatMap((section) => + section.sources.map((doc) => repositoryPath(doc.document)), + ) ?? []), + ...snapshot.config.sources.flatMap((source) => [ + repositoryPath(source.spec), + ...(source.intro ? [repositoryPath(source.intro)] : []), + ]), + ]); + await materializeRepository( + snapshot, + directory, + snapshot.files.filter((file) => paths.has(file.path)), + ); + // Share names, descriptions, and content with the existing generated documentation tools. + const tools = buildConfigToolDefinitions({ ...snapshot.config, languages: [] }, directory); + const resources = [...paths].map((filename) => ({ + name: filename, + uri: `cortex://repository/${filename.split('/').map(encodeURIComponent).join('/')}`, + mimeType: filename.endsWith('.md') + ? 'text/markdown' + : filename.endsWith('.json') + ? 'application/json' + : /\.ya?ml$/.test(filename) + ? 'text/yaml' + : 'text/plain', + text: fs.readFileSync(path.join(directory, filename), 'utf8'), + })); + this.current = { snapshot, tools, resources }; + return this.current; + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + } +} + +export function createRepositoryServer( + documentation: Pick, +): Server { + const server = new Server( + { name: 'cortex-repository-docs', version: '1.0.0' }, + { + capabilities: { tools: { listChanged: true }, resources: { listChanged: true } }, + instructions: + 'Read the project documentation before suggesting integration code. Documentation is fetched from the repository’s current default branch before each request. This server exposes read-only documentation; published package MCP servers can also expose API actions.', + }, + ); + let fingerprint: string | undefined; + const refresh = async () => { + const state = await documentation.refresh(); + const changed = fingerprint !== undefined && fingerprint !== state.snapshot.fingerprint; + fingerprint = state.snapshot.fingerprint; + if (changed) { + await server.sendToolListChanged(); + await server.sendResourceListChanged(); + } + return state; + }; + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: (await refresh()).tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: { type: 'object' as const, properties: {}, additionalProperties: false }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + })), + })); + server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + const state = await refresh(); + const tool = state.tools.find((tool) => tool.name === request.params.name); + if (!tool) + throw new Error('This documentation tool no longer exists. Refresh the tools list.'); + if (Object.keys(request.params.arguments ?? {}).length) + throw new Error('Documentation tools do not take arguments.'); + return { + content: [{ type: 'text', text: tool.content }], + _meta: { repository: state.snapshot.repository, commit: state.snapshot.commit }, + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: 'text', + text: `Could not read current documentation: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + }); + server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: (await refresh()).resources.map(({ text: _text, ...resource }) => resource), + })); + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const state = await refresh(); + const resource = state.resources.find((resource) => resource.uri === request.params.uri); + if (!resource) throw new Error('Resource no longer exists. Refresh the resources list.'); + return { + contents: [{ uri: resource.uri, mimeType: resource.mimeType, text: resource.text }], + _meta: { repository: state.snapshot.repository, commit: state.snapshot.commit }, + }; + }); + return server; +} + +export async function serveRepository(repository: string, token?: string): Promise { + const documentation = new RepositoryDocumentation(repository, token); + const initial = await documentation.refresh(); + const server = createRepositoryServer(documentation); + await server.connect(new StdioServerTransport()); + process.stderr.write( + `Cortex MCP connected to ${initial.snapshot.repository} (${initial.snapshot.branch}). Refreshes from GitHub before each documentation request.\n`, + ); +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 97717eb..de03557 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -16,7 +16,7 @@ async function bootstrap() { } await CommandFactory.run(AppModule, { - logger: ['warn', 'error'], + logger: args[0] === 'mcp-serve' ? false : ['warn', 'error'], serviceErrorHandler: (error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; diff --git a/packages/cli/src/services/repository-docs.ts b/packages/cli/src/services/repository-docs.ts new file mode 100644 index 0000000..b2e9e60 --- /dev/null +++ b/packages/cli/src/services/repository-docs.ts @@ -0,0 +1,136 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + readRepositoryFile, + repositoryPath, + specificationReferences, + type RepositorySnapshot, + type RepositoryFile, +} from '@cortex-docs/core/hosting'; + +export async function mapConcurrent( + items: T[], + concurrency: number, + work: (item: T) => Promise, +): Promise { + let next = 0; + const results = await Promise.allSettled( + Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (next < items.length) await work(items[next++]); + }), + ); + const failure = results.find((result) => result.status === 'rejected'); + if (failure?.status === 'rejected') throw failure.reason; +} + +/** Fetch data files only. Never clone executable files, run hooks, or install repo dependencies. */ +export async function materializeRepository( + snapshot: RepositorySnapshot, + directory: string, + files: RepositoryFile[] = snapshot.files, +): Promise { + await mapConcurrent(files, 4, async (file) => { + const bytes = + file.path === 'cortex.config.yml' + ? Buffer.from(snapshot.configText) + : await readRepositoryFile(snapshot, file); + const destination = path.join(directory, repositoryPath(file.path)); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, bytes); + }); +} + +export function assertLocalSpecReferences(snapshot: RepositorySnapshot, directory: string): void { + const root = path.resolve(directory); + for (const file of snapshot.files.filter( + (f) => /\.(?:json|ya?ml|proto)$/i.test(f.path) && f.path !== 'cortex.config.yml', + )) { + const text = fs.readFileSync(path.join(root, file.path), 'utf8'); + const references = specificationReferences(file.path, text); + for (const reference of references) { + const target = decodeURIComponent(reference.split('#')[0]); + if (!target) continue; + if (/^[a-z][a-z0-9+.-]*:|^[/\\]/i.test(target)) + throw new Error( + `Hosted builds require local specification references: ${file.path} → ${target}`, + ); + const resolved = path.resolve(root, path.dirname(file.path), target); + if (!resolved.startsWith(`${root}${path.sep}`) || !fs.existsSync(resolved)) + throw new Error( + `Specification reference is missing or outside the repository: ${file.path} → ${target}`, + ); + } + } +} + +/** Keep Markdown links useful when the source files move to their generated docs routes. */ +export function rewriteRepositoryMarkdown(snapshot: RepositorySnapshot, directory: string): void { + const docs = snapshot.config.docs?.flatMap((section) => section.sources) ?? []; + const slug = (title: string) => + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + const slugs = docs.map((doc) => slug(doc.title)); + if (slugs.some((value) => !value) || new Set(slugs).size !== slugs.length) + throw new Error( + 'Hosted documentation page titles must produce unique, non-empty URL slugs. Rename duplicate titles in cortex.config.yml.', + ); + const links = new Map( + docs.map((doc) => [repositoryPath(doc.document), `/docs/${slug(doc.title)}`]), + ); + const files = new Set(snapshot.files.map((file) => file.path)); + const markdownPaths = [ + ...links.keys(), + ...snapshot.config.sources.flatMap((source) => + source.intro ? [repositoryPath(source.intro)] : [], + ), + ]; + for (const document of new Set(markdownPaths)) { + const location = path.join(directory, document); + const source = fs.readFileSync(location, 'utf8'); + const rewrite = (href: string) => { + if (/^[a-z][a-z0-9+.-]*:|^\/\/|^#/i.test(href)) return href; + const [pathname, fragment] = href.split('#', 2); + const resolved = path.posix.normalize( + pathname.startsWith('/') + ? pathname.slice(1) + : path.posix.join(path.posix.dirname(document), pathname), + ); + if (resolved.startsWith('../')) return href; + const hash = fragment === undefined ? '' : `#${fragment}`; + if (links.has(resolved)) return `${links.get(resolved)}${hash}`; + if (files.has(resolved) && resolved.startsWith('assets/')) return `/${resolved}${hash}`; + return `${snapshot.repository}/blob/${snapshot.commit}/${resolved.split('/').map(encodeURIComponent).join('/')}${hash}`; + }; + // Inline links and reference definitions; fenced code examples remain unchanged. + let fenced = false; + const output = source + .split('\n') + .map((line) => { + if (/^\s*(```|~~~)/.test(line)) { + fenced = !fenced; + return line; + } + if (fenced) return line; + return line + .replace( + /(!?\[[^\]]*\]\()([^\s)]+)([^)]*\))/g, + (_match, before: string, href: string, after: string) => { + let result = rewrite(href); + if (before.startsWith('!') && result.startsWith(`${snapshot.repository}/blob/`)) + result = result + .replace('https://github.com/', 'https://raw.githubusercontent.com/') + .replace('/blob/', '/'); + return `${before}${result}${after}`; + }, + ) + .replace( + /^(\s*\[[^\]]+\]:\s*)(\S+)/, + (_match, before: string, href: string) => `${before}${rewrite(href)}`, + ); + }) + .join('\n'); + fs.writeFileSync(location, output); + } +} diff --git a/packages/core/__tests__/hosting.test.ts b/packages/core/__tests__/hosting.test.ts new file mode 100644 index 0000000..c257b51 --- /dev/null +++ b/packages/core/__tests__/hosting.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import { + assertProjectName, + parseRepository, + repositoryPath, + readRepositorySnapshot, + sha256, + validateManifest, + specificationReferences, +} from '../src/hosting'; + +function githubFixture( + options: { + code?: string; + doc?: string; + config?: string; + private?: boolean; + push?: boolean; + symlink?: boolean; + missing?: boolean; + } = {}, +) { + const config = + options.config ?? + 'project: example\ndocs:\n - section: Guides\n sources:\n - title: Start\n document: README.md\n'; + const files = [ + ...(!options.missing + ? [ + { + path: 'cortex.config.yml', + type: 'blob', + mode: options.symlink ? '120000' : '100644', + sha: config, + size: Buffer.byteLength(config), + }, + ] + : []), + { path: 'README.md', type: 'blob', mode: '100644', sha: options.doc ?? 'markdown-v1', size: 7 }, + { path: 'src/main.ts', type: 'blob', mode: '100644', sha: options.code ?? 'code-v1', size: 12 }, + ]; + return async (input: string | URL | Request) => { + const url = String(input); + if (url.includes('raw.githubusercontent.com')) return new Response(config); + if (url.includes('/commits/')) + return Response.json({ + sha: `commit-${options.code ?? 'one'}`, + commit: { tree: { sha: 'tree' } }, + }); + if (url.includes('/git/trees/')) return Response.json({ truncated: false, tree: files }); + return Response.json({ + id: 42, + private: options.private ?? false, + default_branch: 'main', + full_name: 'owner/repo', + permissions: { push: options.push ?? true }, + }); + }; +} + +describe('public repository hosting inputs', () => { + it('decodes specification references before validating local files', () => { + expect( + specificationReferences( + 'api.json', + String.raw`{"components":{"schemas":{"Ref":{"$ref":"h\u0074tps://example.com/spec"}}}}`, + ), + ).toEqual(['https://example.com/spec']); + expect(specificationReferences('api.yaml', 'schema:\n $ref: "./types.yaml#/Item"\n')).toEqual([ + './types.yaml#/Item', + ]); + }); + it.each([ + 'api', + 'UPPER', + '-name', + 'name-', + 'with.dot', + 'with_space', + 'two words', + 'xn--example', + 'a'.repeat(64), + ])('rejects invalid or reserved project %s', (name) => + expect(() => assertProjectName(name)).toThrow('project'), + ); + it.each(['a', 'my-project', 'project42', 'a'.repeat(63)])('accepts project %s', (name) => + expect(() => assertProjectName(name)).not.toThrow(), + ); + it('normalizes only public GitHub repository identifiers', () => { + expect(parseRepository('git@github.com:owner/repo.git').url).toBe( + 'https://github.com/owner/repo', + ); + expect(() => parseRepository('https://github.com/owner/repo/tree/main')).toThrow(); + expect(() => parseRepository('https://github.com.evil.test/owner/repo')).toThrow(); + for (const value of ['../secret', '/etc/passwd', 'a/../../b', 'https://example.com/a', 'a\\b']) + expect(() => repositoryPath(value)).toThrow(); + }); + it('skips code-only changes but detects Markdown and configuration changes', async () => { + const first = await readRepositorySnapshot('owner/repo', { fetcher: githubFixture() }); + const code = await readRepositorySnapshot('owner/repo', { + fetcher: githubFixture({ code: 'different' }), + }); + const docs = await readRepositorySnapshot('owner/repo', { + fetcher: githubFixture({ doc: 'markdown-v2' }), + }); + const config = await readRepositorySnapshot('owner/repo', { + fetcher: githubFixture({ config: 'project: example\ntitle: Changed\n' }), + }); + expect(code.commit).not.toBe(first.commit); + expect(code.fingerprint).toBe(first.fingerprint); + expect(docs.fingerprint).not.toBe(first.fingerprint); + expect(config.fingerprint).not.toBe(first.fingerprint); + }); + it('rejects private repositories, unauthorized maintainers, missing files and symlinks', async () => { + await expect( + readRepositorySnapshot('owner/repo', { fetcher: githubFixture({ private: true }) }), + ).rejects.toThrow('public'); + await expect( + readRepositorySnapshot('owner/repo', { + fetcher: githubFixture({ push: false }), + requirePush: true, + }), + ).rejects.toThrow('write access'); + await expect( + readRepositorySnapshot('owner/repo', { fetcher: githubFixture({ missing: true }) }), + ).rejects.toThrow('default branch'); + await expect( + readRepositorySnapshot('owner/repo', { fetcher: githubFixture({ symlink: true }) }), + ).rejects.toThrow('regular file'); + }); + it('rejects unsafe, duplicate or incomplete upload manifests', async () => { + const file = { path: 'index.html', size: 1, sha256: await sha256('x') }; + expect(validateManifest([file])).toEqual([file]); + expect(() => validateManifest([file, file])).toThrow(); + expect(() => validateManifest([{ ...file, path: '../index.html' }])).toThrow(); + expect(() => validateManifest([{ ...file, path: 'page.html' }])).toThrow('index.html'); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 4bb5d1f..774c722 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,11 +32,22 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { + "./hosting": { + "types": "./dist/hosting.d.ts", + "default": "./dist/hosting.js" + }, ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, + "typesVersions": { + "*": { + "hosting": [ + "dist/hosting.d.ts" + ] + } + }, "scripts": { "build": "tsc", "prepack": "npm run clean && npm run build", diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 21261ca..4902615 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -284,6 +284,10 @@ export const cortexConfigSchema = z generators: generatorConfigSchema.optional(), docs: z.array(docsSectionSchema).optional(), mcp: mcpConfigSchema, + deploy: z + .object({ domain: z.string().min(1).optional() }) + .strict() + .optional(), analytics: analyticsConfigSchema, publish: publishConfigSchema, }) diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 3006f14..71a45ab 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -186,6 +186,7 @@ export interface CortexConfig { languages: LanguageConfig[]; docs?: DocsSection[]; mcp?: McpConfig; + deploy?: { domain?: string }; analytics?: AnalyticsConfig; publish?: PublishConfig; } diff --git a/packages/core/src/hosting.ts b/packages/core/src/hosting.ts new file mode 100644 index 0000000..0bc04c8 --- /dev/null +++ b/packages/core/src/hosting.ts @@ -0,0 +1,358 @@ +/** Shared by the CLI and the hosting Worker. Keep this module free of Node APIs. */ +import { load } from 'js-yaml'; +import { cortexConfigSchema } from './config/schema'; +import type { CortexConfig } from './config/types'; + +export const HOSTING_API = 'https://deploy.cortexdocs.dev'; +export const MAX_SITE_BYTES = 50 * 1024 * 1024; +export const MAX_FILE_BYTES = 5 * 1024 * 1024; +export const MAX_SITE_FILES = 2000; +export const IMMUTABLE_TTL = 31_536_000; +export const ROUTING_TTL = 30; +export const RESERVED_PROJECTS = new Set([ + 'www', + 'api', + 'deploy', + 'docs', + 'static', + 'demo', + 'app', + 'admin', + 'status', + 'mail', + 'support', + 'assets', + 'cdn', + 'auth', + 'cortex', + 'cortex-docs', + 'origin', + 'customers', +]); + +export function assertProjectName(project: string): void { + if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(project) || project.startsWith('xn--')) { + throw new Error( + 'Change "project" in cortex.config.yml to 1–63 lowercase letters, digits, or hyphens; start and end with a letter or digit.', + ); + } + if (RESERVED_PROJECTS.has(project)) + throw new Error( + `Project name "${project}" is reserved. Change "project" in cortex.config.yml and commit it to the default branch.`, + ); +} + +export function parseRepository(input: string): { owner: string; name: string; url: string } { + const match = + /^(?:https:\/\/github\.com\/|git@github\.com:)?([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec( + input, + ); + if (!match || match[2] === '.' || match[2] === '..') + throw new Error('Use a public GitHub repository URL: https://github.com/OWNER/REPO.'); + return { owner: match[1], name: match[2], url: `https://github.com/${match[1]}/${match[2]}` }; +} + +export function repositoryPath(value: string): string { + const clean = value.replace(/^(\.\/)+/, ''); + if ( + !clean || + clean.startsWith('/') || + /[\\?#:]/.test(clean) || + [...clean].some((char) => char.charCodeAt(0) < 32) || + clean.split('/').some((p) => !p || p === '.' || p === '..') + ) { + throw new Error(`Expected a file inside the repository: ${value}`); + } + return clean; +} + +export async function sha256(data: string | Uint8Array): Promise { + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export interface RepositoryFile { + path: string; + sha: string; + size: number; +} +export interface RepositorySnapshot { + repository: string; + repositoryId: number; + branch: string; + commit: string; + configText: string; + config: CortexConfig; + files: RepositoryFile[]; + fingerprint: string; +} +export type Fetcher = typeof globalThis.fetch; + +export async function githubJson( + pathname: string, + token?: string, + fetcher: Fetcher = fetch, +): Promise { + const response = await fetcher(`https://api.github.com${pathname}`, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Cortex-Docs', + 'X-GitHub-Api-Version': '2022-11-28', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + signal: AbortSignal.timeout(30_000), + redirect: 'manual', + }); + if (!response.ok) { + if (response.status === 403 || response.status === 429) + throw new Error( + 'GitHub denied this request or its rate limit was reached. Set GITHUB_TOKEN with repository access and retry.', + ); + throw new Error( + `GitHub request failed (${response.status}). Check the public repository URL and GITHUB_TOKEN permissions.`, + ); + } + return (await response.json()) as T; +} + +export async function readRepositoryFile( + snapshot: Pick, + file: RepositoryFile, + fetcher: Fetcher = fetch, +): Promise { + if (file.size > MAX_FILE_BYTES) throw new Error(`Repository file exceeds 5 MiB: ${file.path}`); + const repo = parseRepository(snapshot.repository); + const url = `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/${snapshot.commit}/${file.path.split('/').map(encodeURIComponent).join('/')}`; + const response = await fetcher(url, { + signal: AbortSignal.timeout(30_000), + redirect: 'manual', + cache: 'no-store', + }); + if (!response.ok) + throw new Error(`Cannot read ${file.path} at the default-branch commit (${response.status}).`); + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + if (reader) + while (true) { + const { done, value } = await reader.read(); + if (done) break; + length += value.length; + if (length > file.size || length > MAX_FILE_BYTES) { + await reader.cancel(); + throw new Error(`Repository file exceeds its expected size: ${file.path}`); + } + chunks.push(value); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + if (bytes.byteLength > MAX_FILE_BYTES || bytes.byteLength !== file.size) + throw new Error(`Repository file exceeds its expected size: ${file.path}`); + return bytes; +} + +export function specificationReferences(filename: string, text: string): string[] { + if (/\.proto$/i.test(filename)) + return [...text.matchAll(/\bimport\s+(?:(?:public|weak)\s+)?["']([^"']+)["']/g)].map( + (match) => match[1], + ); + if (/\.(?:graphql|gql)$/i.test(filename)) return []; + const references: string[] = []; + const visited = new WeakSet(); + const visit = (value: unknown): void => { + if (!value || typeof value !== 'object' || visited.has(value)) return; + visited.add(value); + for (const [key, child] of Object.entries(value)) { + if (key === '$ref' && typeof child === 'string') references.push(child); + else visit(child); + } + }; + visit(load(text)); + return references; +} + +export async function readRepositorySnapshot( + input: string, + options: { + token?: string; + requirePush?: boolean; + previous?: RepositorySnapshot; + fetcher?: Fetcher; + } = {}, +): Promise { + const { token, requirePush, previous, fetcher = fetch } = options; + const repo = parseRepository(input); + const base = `/repos/${repo.owner}/${repo.name}`; + const metadata = await githubJson<{ + id: number; + private: boolean; + visibility?: string; + default_branch: string; + full_name: string; + permissions?: { push?: boolean; admin?: boolean; maintain?: boolean }; + }>(base, token, fetcher); + if (metadata.private || (metadata.visibility && metadata.visibility !== 'public')) + throw new Error('Free Cortex hosting and repository MCP require a public GitHub repository.'); + if ( + requirePush && + !metadata.permissions?.push && + !metadata.permissions?.admin && + !metadata.permissions?.maintain + ) + throw new Error('Deploy requires a GitHub token with write access to this repository.'); + const head = await githubJson<{ sha: string; commit: { tree: { sha: string } } }>( + `${base}/commits/${encodeURIComponent(metadata.default_branch)}`, + token, + fetcher, + ); + if (previous?.commit === head.sha && previous.repositoryId === metadata.id) + return { + ...previous, + branch: metadata.default_branch, + repository: `https://github.com/${metadata.full_name}`, + }; + const tree = await githubJson<{ + truncated: boolean; + tree: Array<{ path: string; type: string; mode: string; sha: string; size?: number }>; + }>(`${base}/git/trees/${head.commit.tree.sha}?recursive=1`, token, fetcher); + if (tree.truncated) throw new Error('The repository tree is too large to validate safely.'); + const entries = new Map(tree.tree.filter((f) => f.type === 'blob').map((f) => [f.path, f])); + const configFile = entries.get('cortex.config.yml'); + if (!configFile) + throw new Error( + 'Commit cortex.config.yml to the root of the public repository’s default branch before deploying.', + ); + if (configFile.mode !== '100644' && configFile.mode !== '100755') + throw new Error('cortex.config.yml must be a regular file.'); + if ((configFile.size ?? 0) > 256 * 1024) throw new Error('cortex.config.yml exceeds 256 KiB.'); + const location = { repository: `https://github.com/${metadata.full_name}`, commit: head.sha }; + const configText = new TextDecoder().decode( + await readRepositoryFile(location, { ...configFile, size: configFile.size ?? 0 }, fetcher), + ); + const parsed = cortexConfigSchema.safeParse(load(configText)); + if (!parsed.success) + throw new Error( + `Invalid cortex.config.yml: ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`, + ); + const config = parsed.data as CortexConfig; + if (config.generators || config.sources.some((s) => s.languages.some((l) => l.template))) + throw new Error( + 'Hosted repository builds use Cortex’s built-in templates. Remove custom generator templates from this configuration.', + ); + const paths = new Set(['cortex.config.yml']); + const add = (p?: string) => { + if (p) paths.add(repositoryPath(p)); + }; + for (const section of config.docs ?? []) for (const doc of section.sources) add(doc.document); + for (const source of config.sources) { + add(source.spec); + add(source.intro); + } + for (const value of [config.logo, config.logo_dark, config.logo_light, config.favicon]) + add(value); + for (const section of config.home?.sections ?? []) { + add(section.icon); + add(section.background); + } + // Include static assets. Unrelated package manifests and source files are not build inputs. + for (const item of tree.tree) { + if (item.type === 'blob' && item.path.startsWith('assets/')) add(item.path); + } + const dependencies = config.sources.map((source) => repositoryPath(source.spec)); + const visited = new Set(); + for (let index = 0; index < dependencies.length; index++) { + const filename = dependencies[index]; + if (visited.has(filename)) continue; + visited.add(filename); + if (visited.size > 100) + throw new Error( + 'Hosted builds support at most 100 specification files, including references.', + ); + const entry = entries.get(filename); + if (!entry || !['100644', '100755'].includes(entry.mode)) + throw new Error(`Specification is missing or is a symlink: ${filename}`); + const text = new TextDecoder().decode( + await readRepositoryFile(location, { ...entry, size: entry.size ?? 0 }, fetcher), + ); + for (const reference of specificationReferences(filename, text)) { + const value = decodeURIComponent(reference.split('#')[0]); + if (!value) continue; + if (/^[a-z][a-z0-9+.-]*:|^[/\\]/i.test(value)) + throw new Error( + `Hosted builds require local specification references: ${filename} → ${value}`, + ); + const components = filename.split('/').slice(0, -1); + for (const component of value.split('/')) { + if (component === '..') { + if (!components.length) throw new Error('Specification reference leaves the repository.'); + components.pop(); + } else if (component !== '.' && component) components.push(component); + } + const dependency = repositoryPath(components.join('/')); + add(dependency); + dependencies.push(dependency); + } + } + const files = [...paths].sort().map((filePath) => { + const file = entries.get(filePath); + if (!file || !['100644', '100755'].includes(file.mode)) + throw new Error(`Configured file is missing or is a symlink: ${filePath}`); + if ((file.size ?? 0) > MAX_FILE_BYTES) + throw new Error(`Repository file exceeds 5 MiB: ${filePath}`); + return { path: filePath, sha: file.sha, size: file.size ?? 0 }; + }); + if ( + files.length > MAX_SITE_FILES || + files.reduce((total, file) => total + file.size, 0) > MAX_SITE_BYTES + ) + throw new Error( + 'Configured documentation exceeds the free hosting limit (2,000 files / 50 MiB).', + ); + const fingerprint = await sha256(JSON.stringify(files.map((f) => [f.path, f.sha]))); + return { + ...location, + repositoryId: metadata.id, + branch: metadata.default_branch, + configText, + config, + files, + fingerprint, + }; +} + +export interface UploadFile { + path: string; + size: number; + sha256: string; +} + +export function validateManifest(input: unknown): UploadFile[] { + if (!Array.isArray(input) || !input.length || input.length > MAX_SITE_FILES) + throw new Error('A deployment must contain 1–2,000 files.'); + const paths = new Set(); + let total = 0; + for (const file of input) { + if ( + !file || + typeof file.path !== 'string' || + repositoryPath(file.path) !== file.path || + paths.has(file.path) || + !Number.isSafeInteger(file.size) || + file.size < 0 || + file.size > MAX_FILE_BYTES || + !/^[a-f0-9]{64}$/.test(file.sha256) + ) + throw new Error('Invalid deployment file manifest.'); + if (file.path.startsWith('_cortex/')) throw new Error('The _cortex path is reserved.'); + paths.add(file.path); + total += file.size; + } + if (!paths.has('index.html') || total > MAX_SITE_BYTES) + throw new Error('A deployment needs index.html and must fit within 50 MiB.'); + return input as UploadFile[]; +} diff --git a/packages/docs-site/cortex.config.yml b/packages/docs-site/cortex.config.yml index 8dfa7ce..b954a14 100644 --- a/packages/docs-site/cortex.config.yml +++ b/packages/docs-site/cortex.config.yml @@ -21,6 +21,11 @@ home: label: Getting Started href: /docs sections: + - title: We love open source + description: Turn your existing Markdown into a free documentation site. Connect AI clients to current docs with local MCP. + badge: Free hosting + href: /docs/open-source-hosting + icon: assets/docs-icon.svg - title: Documentation description: Learn about SDK generation, request timeouts, connection recovery, heartbeats, and streaming APIs. badge: Docs @@ -38,6 +43,8 @@ docs: document: docs/quickstart.md - title: Configuration document: docs/configuration.md + - title: Open Source Hosting + document: docs/open-source-hosting.md - section: SDK Generation sources: - title: OpenAPI diff --git a/packages/docs-site/docs/configuration.md b/packages/docs-site/docs/configuration.md index 3f9476d..21aaa97 100644 --- a/packages/docs-site/docs/configuration.md +++ b/packages/docs-site/docs/configuration.md @@ -137,6 +137,21 @@ mcp: See [Custom Generators](/docs/custom-generators) for export commands, template data, and override rules. +## Public repository hosting + +`cortex deploy` uses `project` as the subdomain at `PROJECT.cortexdocs.dev`. For hosting, use 1–63 lowercase letters, digits, or hyphens. + +To attach a custom domain, add: + +```yaml +deploy: + domain: docs.example.org +``` + +Commit this configuration to the public repository's default branch. The deploy command prints the required DNS verification records. + +API `sources` are optional for Markdown-only hosting. See [Open Source Hosting](/docs/open-source-hosting) for the full setup. + ## Appearance Query Parameter Add `?appearance=dark` or `?appearance=light` to any documentation URL. The parameter selects the initial appearance for that page. diff --git a/packages/docs-site/docs/mcp-servers.md b/packages/docs-site/docs/mcp-servers.md index b0fc1ca..f62b1de 100644 --- a/packages/docs-site/docs/mcp-servers.md +++ b/packages/docs-site/docs/mcp-servers.md @@ -1,5 +1,11 @@ # MCP Servers +For public repositories, use `npx -y @cortex-docs/cli mcp-serve https://github.com/OWNER/REPO` to serve current documentation locally. + +This command checks the default branch before each request and needs no npm package publication. See [Open Source Hosting](/docs/open-source-hosting). + +The generation and npm publishing flow below remains available for MCP servers with API action tools. + Cortex Docs can generate [Model Context Protocol](https://modelcontextprotocol.io) servers from API specifications and project documentation. You can replace server, handler, entry point, package, README, and final-file templates. See [Custom Generators](/docs/custom-generators). diff --git a/packages/docs-site/docs/open-source-hosting.md b/packages/docs-site/docs/open-source-hosting.md new file mode 100644 index 0000000..67ce8de --- /dev/null +++ b/packages/docs-site/docs/open-source-hosting.md @@ -0,0 +1,146 @@ +# Free hosting for open source + +We love open source. Your Markdown can serve readers and AI tools from the same repository. + +Cortex provides free documentation hosting for public GitHub projects. The local MCP server is also free and needs no cloud process. + +## Publish your existing Markdown + +You need Node.js 20 or later and write access to the public repository. + +Add `cortex.config.yml` to the root of the repository: + +```yaml +project: my-open-project +title: My Open Project +docs: + - section: Getting Started + sources: + - title: Introduction + document: README.md + - title: Installation + document: docs/installation.md +``` + +Commit the configuration to the default branch. Each configured document must exist on that branch. + +Sign in with `gh auth login`, or set `GITHUB_TOKEN` for an account with repository write access. Then run: + +```bash +npx -y @cortex-docs/cli deploy https://github.com/OWNER/REPO +``` + +Your site appears at `https://my-open-project.cortexdocs.dev` after the build and upload complete. + +From a local checkout, `cortex deploy` also accepts the repository from the Git `origin` remote. + +The deployment always uses the default branch. Local changes and changes on other branches do not affect it. + +## Choose a project name + +The `project` value becomes your subdomain. Use 1–63 lowercase letters, digits, or hyphens. Start and end with a letter or digit. + +Names are assigned to repositories. If another repository owns the name, deployment fails with instructions to change `project`. + +Some platform names, such as `docs`, `deploy`, and `www`, are reserved. + +## Keep the site current + +Run the deploy command after you change the documentation. Cortex compares the configuration and its documentation inputs with the current deployment. + +If those inputs are unchanged, Cortex skips the build and upload. Changes to unrelated application code do not trigger a new deployment. + +Referenced specifications and files in `assets/` also count as documentation inputs. Updated sites become visible within about 30 seconds after activation. + +For automatic deployment, add this GitHub Actions workflow: + +```yaml +name: Documentation +on: + push: + workflow_dispatch: +permissions: + contents: write +concurrency: + group: cortex-docs + cancel-in-progress: false +jobs: + deploy: + if: github.ref_name == github.event.repository.default_branch + runs-on: ubuntu-latest + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npx -y @cortex-docs/cli deploy "https://github.com/${{ github.repository }}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +The workflow needs write permission so the hosting service can verify repository ownership. Cortex does not write to the repository. + +## Connect an AI client + +Use the same public repository for local MCP: + +```bash +npx -y @cortex-docs/cli mcp-serve https://github.com/OWNER/REPO +``` + +For Claude Desktop or Cursor, add this entry to the client configuration: + +```json +{ + "mcpServers": { + "my-open-project": { + "command": "npx", + "args": ["-y", "@cortex-docs/cli", "mcp-serve", "https://github.com/OWNER/REPO"] + } + } +} +``` + +The hosted site's MCP page includes setup instructions for more clients. + +The process runs while the client is connected. Before each documentation request, it checks the default branch for changes. + +Changed documents and configuration are loaded automatically. A failed refresh returns an error so the client does not receive outdated documentation silently. + +Public repositories work without authentication within GitHub's anonymous API limits. For regular use, sign in with `gh` or set `GITHUB_TOKEN` in the client environment. + +A token with read access is sufficient for MCP. The local server exposes documentation tools and specification resources. + +You can still generate and publish an npm MCP package. That option also supports API action tools; see [MCP Servers](/docs/mcp-servers). + +## Use a custom domain + +Add your domain to the configuration and commit it to the default branch: + +```yaml +deploy: + domain: docs.example.org +``` + +Run `cortex deploy`. The output includes a CNAME record and a TXT record that proves ownership. + +Add both records at your DNS provider. Run the command again and add any certificate validation records shown in the output. + +Repeat after DNS propagation until the command reports `active`. An unchanged site skips rebuilding during these checks. + +Your `PROJECT.cortexdocs.dev` address remains available. For an apex domain, your DNS provider must support a suitable CNAME or ALIAS configuration. + +To remove a custom domain, remove `deploy.domain`, commit the change, and deploy again. + +## Hosting limits + +Each deployment supports up to 2,000 files and 50 MiB of static output. Each file must be at most 5 MiB. + +The repository must be public, with `cortex.config.yml` at its root. Hosted builds use Cortex's built-in templates and local specification references. + +Put shared images and styles in `assets/`. Cortex also rewrites relative Markdown links to configured documentation pages. + +Cortex builds the static site on your machine or CI runner and uploads it to R2. It does not run the repository's scripts. + +Versioned files stay cached at the edge for one year. HTML revalidates in browsers so new deployments remain visible. + +Free hosting for maintainers still has operating costs. Cortex operates the hosting service separately from the CLI repository. diff --git a/packages/docs-ui/app/api/asyncapi/route.ts b/packages/docs-ui/app/api/asyncapi/route.ts index 6b4aa09..b9cd7ef 100644 --- a/packages/docs-ui/app/api/asyncapi/route.ts +++ b/packages/docs-ui/app/api/asyncapi/route.ts @@ -5,6 +5,8 @@ import { getDocsUiRoot, locationExists, readTextLocation } from '@/lib/load-loca export const dynamic = 'force-static'; export async function GET() { + if (process.env.CORTEX_HOSTED_REPOSITORY && !process.env.CORTEX_ASYNCAPI_PATH) + return NextResponse.json({ error: 'No AsyncAPI spec configured' }, { status: 404 }); const specPath = process.env.CORTEX_ASYNCAPI_PATH || path.join(getDocsUiRoot(), '..', 'core', '__fixtures__', 'chat-asyncapi.yaml'); diff --git a/packages/docs-ui/app/api/config/route.ts b/packages/docs-ui/app/api/config/route.ts index 36a6a69..1961457 100644 --- a/packages/docs-ui/app/api/config/route.ts +++ b/packages/docs-ui/app/api/config/route.ts @@ -76,7 +76,10 @@ export async function GET() { theme: (raw?.theme as string) ?? 'system', hasSources: Array.isArray(sources) && sources.length > 0, hasDocs: Array.isArray(docs) && docs.length > 0, - hasMcp: !!mcp || (Array.isArray(sources) && sources.length > 0), + hasMcp: + !!process.env.CORTEX_HOSTED_REPOSITORY || + !!mcp || + (Array.isArray(sources) && sources.length > 0), home: home ? { title: home.title, diff --git a/packages/docs-ui/app/api/mcp/route.ts b/packages/docs-ui/app/api/mcp/route.ts index a5b223f..fec5e52 100644 --- a/packages/docs-ui/app/api/mcp/route.ts +++ b/packages/docs-ui/app/api/mcp/route.ts @@ -5,8 +5,10 @@ import { gitRepositoryUrl, normalizeRepositoryUrl } from '@cortex-docs/core'; import { locationExists } from '@/lib/load-location'; import { buildToolInfos, + buildConfigToolDefinitions, generateReadme, generateSetupSection, + generateRepositorySetupSection, renderMcpTemplate, type McpToolInfo, } from '@cortex-docs/mcp-gen'; @@ -105,14 +107,20 @@ export async function GET() { } } - const tools = buildToolInfos({ - spec, - asyncApiSpec, - graphqlSpec, - openRpcSpec, - config, - configDir, - }); + const hostedRepository = process.env.CORTEX_HOSTED_REPOSITORY; + const tools = + hostedRepository && config && configDir + ? buildConfigToolDefinitions({ ...config, languages: [] }, configDir).map( + ({ content: _content, ...tool }) => tool, + ) + : buildToolInfos({ + spec, + asyncApiSpec, + graphqlSpec, + openRpcSpec, + config, + configDir, + }); const configSources = config?.sources ?? []; @@ -154,23 +162,25 @@ export async function GET() { } } - const instructions = [ - `You are an AI coding assistant for ${title}.`, - '', - ...(introBlocks.length > 0 ? ['## Overview', '', ...introBlocks, ''] : []), - '## How to help users', - '', - '1. ALWAYS prefer the SDK over raw HTTP calls. The SDK provides typed methods, error handling, and auth built in.', - sdkBlock ? `\nAvailable SDKs:\n${sdkBlock}\n` : '', - "2. When the user's language has an SDK, show the install command first, then a working code example using the SDK client.", - "3. Adapt examples to the user's existing codebase — match their import style, error handling patterns, and variable naming.", - "4. Only fall back to direct HTTP/curl calls if no SDK exists for the user's language.", - '5. Use the `docs_*` and `sdk_*` tools to look up quickstart guides and SDK references before writing code.', - '', - '## Tool categories', - '', - '- `docs_*`, `intro_*` — Documentation pages, intro guides, and SDK references. Read these first for context.', - ].join('\n'); + const instructions = hostedRepository + ? `Read the documentation tools before writing integration code for ${title}. The local MCP server refreshes configuration and Markdown from the repository’s default branch before each request.` + : [ + `You are an AI coding assistant for ${title}.`, + '', + ...(introBlocks.length > 0 ? ['## Overview', '', ...introBlocks, ''] : []), + '## How to help users', + '', + '1. ALWAYS prefer the SDK over raw HTTP calls. The SDK provides typed methods, error handling, and auth built in.', + sdkBlock ? `\nAvailable SDKs:\n${sdkBlock}\n` : '', + "2. When the user's language has an SDK, show the install command first, then a working code example using the SDK client.", + "3. Adapt examples to the user's existing codebase — match their import style, error handling patterns, and variable naming.", + "4. Only fall back to direct HTTP/curl calls if no SDK exists for the user's language.", + '5. Use the `docs_*` and `sdk_*` tools to look up quickstart guides and SDK references before writing code.', + '', + '## Tool categories', + '', + '- `docs_*`, `intro_*` — Documentation pages, intro guides, and SDK references. Read these first for context.', + ].join('\n'); const mcpPackageName = config?.mcp?.package_name ?? `@${config?.project ?? 'my-org'}/mcp`; @@ -188,7 +198,13 @@ export async function GET() { instructions, }; - const setupMarkdown = generateSetupSection(readmeData); + const setupMarkdown = hostedRepository + ? generateRepositorySetupSection({ + repository: hostedRepository, + serverName, + publishedPackage: config?.mcp?.package_name, + }) + : generateSetupSection(readmeData); const setupHtml = await renderMarkdown(setupMarkdown); const customReadme = renderMcpTemplate( 'readme', @@ -219,9 +235,11 @@ export async function GET() { const mcpInfo: McpInfo = { serverName, packageName: mcpPackageName, - githubRepository: config?.mcp?.github_repository - ? normalizeRepositoryUrl(config.mcp.github_repository) - : undefined, + githubRepository: + hostedRepository ?? + (config?.mcp?.github_repository + ? normalizeRepositoryUrl(config.mcp.github_repository) + : undefined), instructions, instructionsHtml, tools, diff --git a/packages/docs-ui/app/api/sdk-readme/route.ts b/packages/docs-ui/app/api/sdk-readme/route.ts index 3b56a40..c272ff1 100644 --- a/packages/docs-ui/app/api/sdk-readme/route.ts +++ b/packages/docs-ui/app/api/sdk-readme/route.ts @@ -63,6 +63,8 @@ const DISPLAY_NAMES: Record = { }; export async function GET(request: Request) { + if (process.env.CORTEX_HOSTED_REPOSITORY && !process.env.CORTEX_SPEC_PATH) + return NextResponse.json({ error: 'No OpenAPI spec configured' }, { status: 404 }); const lang = process.env.CORTEX_STATIC_EXPORT === '1' ? null : new URL(request.url).searchParams.get('lang'); @@ -96,17 +98,22 @@ export async function GET(request: Request) { const fallbackPkg = spec.info.title.toLowerCase().replace(/\s+/g, '-'); const hasWs = !!( process.env.CORTEX_ASYNCAPI_PATH || - fs.existsSync(path.join(getDocsUiRoot(), '..', 'core', '__fixtures__', 'chat-asyncapi.yaml')) + (!process.env.CORTEX_HOSTED_REPOSITORY && + fs.existsSync( + path.join(getDocsUiRoot(), '..', 'core', '__fixtures__', 'chat-asyncapi.yaml'), + )) ); const hasGql = !!( process.env.CORTEX_GRAPHQL_PATH || - fs.existsSync(path.join(getDocsUiRoot(), '..', 'core', '__fixtures__', 'petstore.graphql')) + (!process.env.CORTEX_HOSTED_REPOSITORY && + fs.existsSync(path.join(getDocsUiRoot(), '..', 'core', '__fixtures__', 'petstore.graphql'))) ); const hasOpenRpc = !!( process.env.CORTEX_OPENRPC_PATH || - fs.existsSync( - path.join(getDocsUiRoot(), '..', 'core', '__fixtures__', 'petstore-openrpc.json'), - ) + (!process.env.CORTEX_HOSTED_REPOSITORY && + fs.existsSync( + path.join(getDocsUiRoot(), '..', 'core', '__fixtures__', 'petstore-openrpc.json'), + )) ); const configPkgNames: Record = {}; diff --git a/packages/docs-ui/app/api/sdk-snippets/route.ts b/packages/docs-ui/app/api/sdk-snippets/route.ts index 6c4b7b2..c2c26c8 100644 --- a/packages/docs-ui/app/api/sdk-snippets/route.ts +++ b/packages/docs-ui/app/api/sdk-snippets/route.ts @@ -603,7 +603,7 @@ export async function GET() { } // If still nothing, fall back to fixture paths - if (configSources.length === 0) { + if (configSources.length === 0 && !process.env.CORTEX_HOSTED_REPOSITORY) { configSources = [ { title: 'REST API', diff --git a/packages/docs-ui/app/api/spec/route.ts b/packages/docs-ui/app/api/spec/route.ts index a76007f..28d73ad 100644 --- a/packages/docs-ui/app/api/spec/route.ts +++ b/packages/docs-ui/app/api/spec/route.ts @@ -28,6 +28,7 @@ function resolveSpecPath(): string | null { return process.env.CORTEX_SPEC_PATH; } + if (process.env.CORTEX_HOSTED_REPOSITORY) return null; const fallback = path.join(getDocsUiRoot(), '..', 'core', '__fixtures__', 'petstore.yaml'); if (fs.existsSync(fallback)) return fallback; return null; diff --git a/packages/docs-ui/app/layout.tsx b/packages/docs-ui/app/layout.tsx index 4e333b0..579714e 100644 --- a/packages/docs-ui/app/layout.tsx +++ b/packages/docs-ui/app/layout.tsx @@ -163,7 +163,10 @@ function readSiteConfig(): LoadedSiteConfig { theme, hasSources: Array.isArray(sources) && sources.length > 0, hasDocs: Array.isArray(docs) && docs.length > 0, - hasMcp: !!mcp || (Array.isArray(sources) && sources.length > 0), + hasMcp: + !!process.env.CORTEX_HOSTED_REPOSITORY || + !!mcp || + (Array.isArray(sources) && sources.length > 0), analytics, home: home ? { diff --git a/packages/docs-ui/app/page.tsx b/packages/docs-ui/app/page.tsx index 1e817a8..ecfb004 100644 --- a/packages/docs-ui/app/page.tsx +++ b/packages/docs-ui/app/page.tsx @@ -101,7 +101,7 @@ const DEFAULT_SECTIONS: HomeSection[] = [ ]; export default function Home() { - const { title: siteTitle, home: initialHome } = useSiteConfig(); + const { title: siteTitle, home: initialHome, hasSources, hasDocs, hasMcp } = useSiteConfig(); const [home, setHome] = useState(initialHome); const fetchConfig = useCallback(() => { @@ -162,9 +162,25 @@ export default function Home() { const title = home?.title ?? siteTitle ?? 'API Docs'; const description = home?.description ?? - 'Explore the full API surface, grab a client SDK, or wire up AI coding agents via our MCP for faster integration.'; + (hasSources === false + ? 'Explore the project documentation and connect your AI client to the latest guides.' + : 'Explore the full API surface, grab a client SDK, or wire up AI coding agents via our MCP for faster integration.'); const cta = home?.cta ?? { label: 'Getting Started', href: '/docs' }; - const sections = home?.sections ?? DEFAULT_SECTIONS; + const sections = home?.sections ?? [ + ...(hasDocs + ? [ + { + title: 'Documentation', + description: 'Read the guides and get started with the project.', + href: '/docs', + badge: 'Guides', + }, + ] + : []), + ...DEFAULT_SECTIONS.filter((section) => + section.href === '/mcp' ? hasMcp !== false : hasSources !== false, + ), + ]; return (
diff --git a/packages/mcp-gen/__tests__/repository-setup.test.ts b/packages/mcp-gen/__tests__/repository-setup.test.ts new file mode 100644 index 0000000..f2ec2a5 --- /dev/null +++ b/packages/mcp-gen/__tests__/repository-setup.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from 'vitest'; +import { generateRepositorySetupSection } from '../src/repository-setup'; + +it('uses repository MCP in client configurations and retains the optional npm package', () => { + const setup = generateRepositorySetupSection({ + repository: 'https://github.com/owner/repo', + serverName: 'example', + publishedPackage: '@example/mcp', + }); + expect(setup).toContain('npx -y @cortex-docs/cli mcp-serve https://github.com/owner/repo'); + const json = [...setup.matchAll(/```json\n([\s\S]*?)\n```/g)].map((match) => + JSON.parse(match[1]), + ); + expect(json[0].mcpServers.example.args).toEqual([ + '-y', + '@cortex-docs/cli', + 'mcp-serve', + 'https://github.com/owner/repo', + ]); + expect(json[1].servers.example.command).toBe('npx'); + expect(setup).toContain('npx @example/mcp'); + expect( + generateRepositorySetupSection({ + repository: 'https://github.com/owner/repo', + serverName: 'example', + }), + ).not.toContain('### Published MCP package'); +}); diff --git a/packages/mcp-gen/src/index.ts b/packages/mcp-gen/src/index.ts index eab5265..2d6021d 100644 --- a/packages/mcp-gen/src/index.ts +++ b/packages/mcp-gen/src/index.ts @@ -34,3 +34,4 @@ export { generateToolsSection, type ReadmeData, } from './readme-content'; +export { generateRepositorySetupSection } from './repository-setup'; diff --git a/packages/mcp-gen/src/repository-setup.ts b/packages/mcp-gen/src/repository-setup.ts new file mode 100644 index 0000000..8a0f54a --- /dev/null +++ b/packages/mcp-gen/src/repository-setup.ts @@ -0,0 +1,75 @@ +export function generateRepositorySetupSection(options: { + repository: string; + serverName: string; + publishedPackage?: string; +}): string { + const { repository, serverName, publishedPackage } = options; + const args = ['-y', '@cortex-docs/cli', 'mcp-serve', repository]; + const command = `npx ${args.join(' ')}`; + const config = (key: string) => + JSON.stringify({ [key]: { [serverName]: { command: 'npx', args } } }, null, 2); + const lines = [ + '## Client Setup Guide', + '', + 'Run this project’s documentation MCP server locally for free. Node.js 20+ is required.', + '', + '```bash', + command, + '```', + '', + 'The server reads cortex.config.yml and its Markdown documents from the public repository’s default branch. It checks GitHub before every documentation request and downloads changed files automatically. No MCP package publishing or running cloud server is required.', + '', + 'This connection provides documentation tools and specification resources. It does not execute the project’s API operations.', + '', + '### Claude Code', + '', + '```bash', + `claude mcp add ${serverName} -- ${command}`, + '```', + '', + '### Claude Desktop, Cursor, Windsurf, and Cline', + '', + 'Add this entry to your client’s MCP configuration:', + '', + '```json', + config('mcpServers'), + '```', + '', + '### VS Code', + '', + 'Add this configuration to .vscode/mcp.json:', + '', + '```json', + config('servers'), + '```', + '', + '### Codex', + '', + 'Add this configuration to ~/.codex/config.toml:', + '', + '```toml', + `[mcp_servers.${JSON.stringify(serverName)}]`, + 'command = "npx"', + `args = ${JSON.stringify(args)}`, + '```', + '', + '### Updates and GitHub access', + '', + 'The server runs while your MCP client is connected. Each request reads the current default branch; failed refreshes return an error instead of silently serving old documentation. New or removed documentation tools trigger a tools-list update.', + '', + 'Public repositories work without a token within GitHub’s anonymous API limits. For regular use, sign in with gh auth login or provide GITHUB_TOKEN in the MCP process environment. A read-only token is sufficient for mcp-serve.', + '', + ]; + if (publishedPackage) + lines.push( + '### Published MCP package', + '', + 'You can also use the existing published package. Its documentation updates when the maintainer publishes a new version, and it may include API action tools.', + '', + '```bash', + `npx ${publishedPackage}`, + '```', + '', + ); + return lines.join('\n'); +} From 6b788bdc9c262de45398e64d5c1f28416bd975e1 Mon Sep 17 00:00:00 2001 From: Nick Chisiu <8492343+nickchisiu@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:20:07 +0300 Subject: [PATCH 7/9] feat: support direct Cloudflare asset publishing --- packages/cli/src/commands/deploy/deploy.command.ts | 4 ++-- packages/docs-site/docs/open-source-hosting.md | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/deploy/deploy.command.ts b/packages/cli/src/commands/deploy/deploy.command.ts index d916604..8c4b26d 100644 --- a/packages/cli/src/commands/deploy/deploy.command.ts +++ b/packages/cli/src/commands/deploy/deploy.command.ts @@ -68,7 +68,7 @@ export async function deploymentRequest( ...init.headers, }, redirect: 'error', - signal: AbortSignal.timeout(60_000), + signal: AbortSignal.timeout(route.endsWith('/finalize') ? 300_000 : 60_000), }); let data: Record; try { @@ -191,7 +191,7 @@ export class DeployCommand extends CommandRunner { this.logger.success( `${result.status === 'unchanged' ? 'Unchanged — skipped build and upload' : 'Deployed'}: ${result.url}`, ); - this.logger.info('Updates become visible across the edge within 30 seconds.'); + this.logger.info('Cloudflare serves the documentation directly from Static Assets.'); const domain = await deploymentRequest( api, `/v1/projects/${snapshot.config.project}/domain`, diff --git a/packages/docs-site/docs/open-source-hosting.md b/packages/docs-site/docs/open-source-hosting.md index 67ce8de..4d90834 100644 --- a/packages/docs-site/docs/open-source-hosting.md +++ b/packages/docs-site/docs/open-source-hosting.md @@ -50,7 +50,7 @@ Run the deploy command after you change the documentation. Cortex compares the c If those inputs are unchanged, Cortex skips the build and upload. Changes to unrelated application code do not trigger a new deployment. -Referenced specifications and files in `assets/` also count as documentation inputs. Updated sites become visible within about 30 seconds after activation. +Referenced specifications and files in `assets/` also count as documentation inputs. Cloudflare distributes each completed deployment across its edge network. For automatic deployment, add this GitHub Actions workflow: @@ -139,8 +139,10 @@ The repository must be public, with `cortex.config.yml` at its root. Hosted buil Put shared images and styles in `assets/`. Cortex also rewrites relative Markdown links to configured documentation pages. -Cortex builds the static site on your machine or CI runner and uploads it to R2. It does not run the repository's scripts. +Cortex builds the static site on your machine or CI runner. It stages the files in R2, then publishes them through Cloudflare Static Assets. It does not run the repository's scripts. -Versioned files stay cached at the edge for one year. HTML revalidates in browsers so new deployments remain visible. +Cloudflare caches the static files at the edge. Hashed browser assets use a one-year TTL. HTML revalidates in browsers so new deployments remain visible. + +Documentation visits use direct static delivery without a per-request Worker charge. Custom domains are free for project maintainers. Free hosting for maintainers still has operating costs. Cortex operates the hosting service separately from the CLI repository. From f4b5a8d78041e920b856b2ece2fa5edfe43a19ba Mon Sep 17 00:00:00 2001 From: Nick Chisiu <8492343+nickchisiu@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:21:38 +0300 Subject: [PATCH 8/9] fix: avoid conflicting static asset cache headers --- packages/cli/src/commands/deploy/deploy.command.ts | 1 - packages/docs-ui/public/_headers | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/cli/src/commands/deploy/deploy.command.ts b/packages/cli/src/commands/deploy/deploy.command.ts index 8c4b26d..fc6dbf8 100644 --- a/packages/cli/src/commands/deploy/deploy.command.ts +++ b/packages/cli/src/commands/deploy/deploy.command.ts @@ -191,7 +191,6 @@ export class DeployCommand extends CommandRunner { this.logger.success( `${result.status === 'unchanged' ? 'Unchanged — skipped build and upload' : 'Deployed'}: ${result.url}`, ); - this.logger.info('Cloudflare serves the documentation directly from Static Assets.'); const domain = await deploymentRequest( api, `/v1/projects/${snapshot.config.project}/domain`, diff --git a/packages/docs-ui/public/_headers b/packages/docs-ui/public/_headers index 0c3f573..47d4563 100644 --- a/packages/docs-ui/public/_headers +++ b/packages/docs-ui/public/_headers @@ -1,5 +1,4 @@ /* - Cache-Control: public,max-age=0,must-revalidate X-Cortex-Hosting: cloudflare-static-assets /_next/static/* From 6dd49327149b36d38638621a93b94bbf19ee3aaa Mon Sep 17 00:00:00 2001 From: Nick Chisiu <8492343+nickchisiu@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:34:36 +0300 Subject: [PATCH 9/9] fix: follow Markdown heading links in static docs --- e2e/docs-ui-static.spec.ts | 11 ++++++ packages/docs-ui/app/docs/[slug]/page.tsx | 47 +++++++++++++++++++++++ packages/docs-ui/scripts/prepare-demo.mjs | 2 + 3 files changed, 60 insertions(+) diff --git a/e2e/docs-ui-static.spec.ts b/e2e/docs-ui-static.spec.ts index 0f459d2..20825b0 100644 --- a/e2e/docs-ui-static.spec.ts +++ b/e2e/docs-ui-static.spec.ts @@ -55,6 +55,17 @@ test.describe('Cloudflare Static Assets export', () => { await expect(page.getByText('TypeScript').first()).toBeVisible(); }); + test('follows Markdown fragments after static content loads', async ({ page }) => { + await page.setViewportSize({ width: 1000, height: 400 }); + await page.goto('/docs/quickstart#mcp-server'); + await expect(page.locator('article #user-content-mcp-server')).toBeInViewport({ ratio: 1 }); + + await page.goto('/docs/quickstart'); + await page.getByRole('link', { name: 'next steps', exact: true }).click(); + await expect(page).toHaveURL(/#next-steps$/); + await expect(page.locator('article #user-content-next-steps')).toBeInViewport({ ratio: 1 }); + }); + for (const width of [320, 390]) { test(`keeps documentation readable and navigation usable at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 844 }); diff --git a/packages/docs-ui/app/docs/[slug]/page.tsx b/packages/docs-ui/app/docs/[slug]/page.tsx index 5fa619b..9885aff 100644 --- a/packages/docs-ui/app/docs/[slug]/page.tsx +++ b/packages/docs-ui/app/docs/[slug]/page.tsx @@ -134,6 +134,53 @@ export default function DocSlugPage({ params }: { params: Promise<{ slug: string const tocItems = useMemo(() => (activeDoc ? extractToc(activeDoc.content) : []), [activeDoc]); + useEffect(() => { + const article = articleRef.current; + if (!article || !activeDoc) return; + + // Sanitization prefixes heading IDs. Keep the original Markdown fragments usable, + // including links opened before the client has fetched the document content. + const scrollToFragment = (hash: string) => { + if (!hash) return false; + let id: string; + try { + id = decodeURIComponent(hash.slice(1)); + } catch { + return false; + } + const target = + article.querySelector(`#${CSS.escape(id)}`) ?? + article.querySelector(`#${CSS.escape(`user-content-${id}`)}`); + if (!target) return false; + target.scrollIntoView({ block: 'start' }); + return true; + }; + const onHashChange = () => scrollToFragment(window.location.hash); + const onClick = (event: MouseEvent) => { + if (event.button || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const anchor = + event.target instanceof Element ? event.target.closest('a[href]') : null; + if (!anchor || anchor.target || anchor.hasAttribute('download')) return; + const url = new URL(anchor.href); + if ( + url.origin !== window.location.origin || + url.pathname !== window.location.pathname || + url.search !== window.location.search || + !scrollToFragment(url.hash) + ) + return; + event.preventDefault(); + if (window.location.hash !== url.hash) window.history.pushState(null, '', url.hash); + }; + onHashChange(); + article.addEventListener('click', onClick); + window.addEventListener('hashchange', onHashChange); + return () => { + article.removeEventListener('click', onClick); + window.removeEventListener('hashchange', onHashChange); + }; + }, [activeDoc]); + useEffect(() => { const container = mainRef.current; if (!container || tocItems.length === 0) return; diff --git a/packages/docs-ui/scripts/prepare-demo.mjs b/packages/docs-ui/scripts/prepare-demo.mjs index 4abd80a..eb8027c 100644 --- a/packages/docs-ui/scripts/prepare-demo.mjs +++ b/packages/docs-ui/scripts/prepare-demo.mjs @@ -16,6 +16,8 @@ const quickstart = `# Quickstart Welcome to your API documentation! This guide will help you get started. +Read about the [MCP server](#mcp-server) or skip to [next steps](#next-steps). + ## API Reference Browse the full API reference to see all available endpoints, request/response schemas, and authentication details.