From ef12f9f1c7fc1ed03dc197588b677380d50f4bf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20G=C3=B3mez=20Jim=C3=A9nez?= Date: Mon, 25 May 2026 18:35:31 +0200 Subject: [PATCH 01/11] feat(docs): migrate docs site to MDX content collection with public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-page .astro files with an Astro content collection (src/content/docs/) so docs are a single source of truth that drives both the rendered website and a public, machine-readable API consumed by the Discord bot (and future clients). Site changes: - Add @astrojs/mdx integration. - Define a docs collection with frontmatter schema (title, description, order, locale) loaded via glob. - New dynamic route src/pages/docs/[...slug].astro renders entries; the layout reads the collection for nav/breadcrumb so adding a doc no longer requires editing route or nav lists. - Extract reusable rich UI primitives (ModelCard, LimitationsCard, EndpointGrid, FieldList, Callout) so MDX stays readable. - Polish .docs-content typography (h3/links/lists/blockquotes/tables/ images), wrap markdown
 with a labeled toolbar + copy button,
  and constrain the inline-code background rule with :not(pre) > code
  so Shiki blocks no longer pick up the "selected" look.

Public API:
- GET /api/docs/manifest.json — versioned index with per-entry
  sha256 content hash and contentUrl.
- GET /api/docs/[slug].md — server-renders the live page, extracts the
  article between  /  markers,
  and converts the HTML to clean Markdown via lib/htmlToText.ts.
- Responses set Cache-Control, ETag and X-Content-Hash.

Co-Authored-By: Claude Opus 4.7 
---
 astro.config.mjs                              |    3 +-
 package-lock.json                             |  846 ++++++++++++-
 package.json                                  |    1 +
 src/components/docs/Callout.astro             |   60 +
 src/components/docs/EndpointGrid.astro        |   28 +
 src/components/docs/FieldList.astro           |   38 +
 src/components/docs/LimitationsCard.astro     |   27 +
 src/components/docs/ModelCard.astro           |   53 +
 src/content.config.ts                         |   14 +
 src/content/docs/agents.md                    |  112 ++
 src/content/docs/api.mdx                      |  653 ++++++++++
 src/content/docs/apps.md                      |   60 +
 .../docs/examples.md}                         |  291 ++---
 src/content/docs/getting-started.md           |   38 +
 src/content/docs/intro.md                     |   26 +
 src/content/docs/models.mdx                   |  161 +++
 src/layouts/Docs.astro                        |  132 +-
 src/lib/contentHash.ts                        |    7 +
 src/lib/htmlToText.ts                         |  Bin 0 -> 4464 bytes
 src/pages/api/docs/[slug].md.ts               |   63 +
 src/pages/api/docs/manifest.json.ts           |   53 +
 src/pages/docs/[...slug].astro                |   19 +
 src/pages/docs/agents.astro                   |  253 ----
 src/pages/docs/api.astro                      | 1062 -----------------
 src/pages/docs/apps.astro                     |  151 ---
 src/pages/docs/getting-started.astro          |   78 --
 src/pages/docs/index.astro                    |   73 --
 src/pages/docs/models.astro                   |  403 -------
 src/styles/global.css                         |  145 ++-
 29 files changed, 2640 insertions(+), 2210 deletions(-)
 create mode 100644 src/components/docs/Callout.astro
 create mode 100644 src/components/docs/EndpointGrid.astro
 create mode 100644 src/components/docs/FieldList.astro
 create mode 100644 src/components/docs/LimitationsCard.astro
 create mode 100644 src/components/docs/ModelCard.astro
 create mode 100644 src/content.config.ts
 create mode 100644 src/content/docs/agents.md
 create mode 100644 src/content/docs/api.mdx
 create mode 100644 src/content/docs/apps.md
 rename src/{pages/docs/examples.astro => content/docs/examples.md} (50%)
 create mode 100644 src/content/docs/getting-started.md
 create mode 100644 src/content/docs/intro.md
 create mode 100644 src/content/docs/models.mdx
 create mode 100644 src/lib/contentHash.ts
 create mode 100644 src/lib/htmlToText.ts
 create mode 100644 src/pages/api/docs/[slug].md.ts
 create mode 100644 src/pages/api/docs/manifest.json.ts
 create mode 100644 src/pages/docs/[...slug].astro
 delete mode 100644 src/pages/docs/agents.astro
 delete mode 100644 src/pages/docs/api.astro
 delete mode 100644 src/pages/docs/apps.astro
 delete mode 100644 src/pages/docs/getting-started.astro
 delete mode 100644 src/pages/docs/index.astro
 delete mode 100644 src/pages/docs/models.astro

diff --git a/astro.config.mjs b/astro.config.mjs
index 9a6a568..415907f 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -2,6 +2,7 @@
 import { defineConfig } from 'astro/config';
 
 import cloudflare from '@astrojs/cloudflare';
+import mdx from '@astrojs/mdx';
 import preact from '@astrojs/preact';
 import tailwindcss from '@tailwindcss/vite';
 import rehypePrettyCode from 'rehype-pretty-code';
@@ -10,7 +11,7 @@ import rehypePrettyCode from 'rehype-pretty-code';
 export default defineConfig({
   output: 'server',
   adapter: cloudflare(),
-  integrations: [preact()],
+  integrations: [preact(), mdx()],
 
   // We do not use Astro sessions in v1 (no auth). The Cloudflare adapter
   // otherwise auto-enables a KV-backed session driver and tries to inject a
diff --git a/package-lock.json b/package-lock.json
index d36cbb9..f5272fc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
       "version": "0.0.2",
       "dependencies": {
         "@astrojs/cloudflare": "^13.1.3",
+        "@astrojs/mdx": "^5.0.6",
         "@astrojs/preact": "^5.0.2",
         "@tailwindcss/vite": "^4.2.2",
         "astro": "^6.0.8",
@@ -154,6 +155,83 @@
         "vfile": "^6.0.3"
       }
     },
+    "node_modules/@astrojs/mdx": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-5.0.6.tgz",
+      "integrity": "sha512-4dKe0ZMmqujofPNDHahzClkwinn9f8jHPcaXcgdGvPAlboD2mjzkUCofli2cBnxYAkdfhC6d50gBJ8i/cH8gHw==",
+      "license": "MIT",
+      "dependencies": {
+        "@astrojs/markdown-remark": "7.1.2",
+        "@mdx-js/mdx": "^3.1.1",
+        "acorn": "^8.16.0",
+        "es-module-lexer": "^2.0.0",
+        "estree-util-visit": "^2.0.0",
+        "hast-util-to-html": "^9.0.5",
+        "piccolore": "^0.1.3",
+        "rehype-raw": "^7.0.0",
+        "remark-gfm": "^4.0.1",
+        "remark-smartypants": "^3.0.2",
+        "source-map": "^0.7.6",
+        "unist-util-visit": "^5.1.0",
+        "vfile": "^6.0.3"
+      },
+      "engines": {
+        "node": ">=22.12.0"
+      },
+      "peerDependencies": {
+        "astro": "^6.0.0"
+      }
+    },
+    "node_modules/@astrojs/mdx/node_modules/@astrojs/internal-helpers": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.9.1.tgz",
+      "integrity": "sha512-1pWuARqYom/TzuU3+0ZugsTrKlUydWKuULmDqSMTuonY+9IRDUEGKX/8PXQ1nBxRq3w85uGtd9q9SXfqEldMIQ==",
+      "license": "MIT",
+      "dependencies": {
+        "picomatch": "^4.0.4"
+      }
+    },
+    "node_modules/@astrojs/mdx/node_modules/@astrojs/markdown-remark": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.1.2.tgz",
+      "integrity": "sha512-caXZ4Dc2St2dW8luEg22GlP0gupLdztCTQE4EzZOxW1pqWXz9mbeJEuHUkgDYcKWW8tjIHkydYDhWLVoxJ327Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@astrojs/internal-helpers": "0.9.1",
+        "@astrojs/prism": "4.0.2",
+        "github-slugger": "^2.0.0",
+        "hast-util-from-html": "^2.0.3",
+        "hast-util-to-text": "^4.0.2",
+        "js-yaml": "^4.1.1",
+        "mdast-util-definitions": "^6.0.0",
+        "rehype-raw": "^7.0.0",
+        "rehype-stringify": "^10.0.1",
+        "remark-gfm": "^4.0.1",
+        "remark-parse": "^11.0.0",
+        "remark-rehype": "^11.1.2",
+        "remark-smartypants": "^3.0.2",
+        "retext-smartypants": "^6.2.0",
+        "shiki": "^4.0.0",
+        "smol-toml": "^1.6.0",
+        "unified": "^11.0.5",
+        "unist-util-remove-position": "^5.0.0",
+        "unist-util-visit": "^5.1.0",
+        "unist-util-visit-parents": "^6.0.2",
+        "vfile": "^6.0.3"
+      }
+    },
+    "node_modules/@astrojs/mdx/node_modules/@astrojs/prism": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz",
+      "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==",
+      "license": "MIT",
+      "dependencies": {
+        "prismjs": "^1.30.0"
+      },
+      "engines": {
+        "node": ">=22.12.0"
+      }
+    },
     "node_modules/@astrojs/preact": {
       "version": "5.1.1",
       "resolved": "https://registry.npmjs.org/@astrojs/preact/-/preact-5.1.1.tgz",
@@ -1696,6 +1774,52 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
+    "node_modules/@mdx-js/mdx": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz",
+      "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "@types/estree-jsx": "^1.0.0",
+        "@types/hast": "^3.0.0",
+        "@types/mdx": "^2.0.0",
+        "acorn": "^8.0.0",
+        "collapse-white-space": "^2.0.0",
+        "devlop": "^1.0.0",
+        "estree-util-is-identifier-name": "^3.0.0",
+        "estree-util-scope": "^1.0.0",
+        "estree-walker": "^3.0.0",
+        "hast-util-to-jsx-runtime": "^2.0.0",
+        "markdown-extensions": "^2.0.0",
+        "recma-build-jsx": "^1.0.0",
+        "recma-jsx": "^1.0.0",
+        "recma-stringify": "^1.0.0",
+        "rehype-recma": "^1.0.0",
+        "remark-mdx": "^3.0.0",
+        "remark-parse": "^11.0.0",
+        "remark-rehype": "^11.0.0",
+        "source-map": "^0.7.0",
+        "unified": "^11.0.0",
+        "unist-util-position-from-estree": "^2.0.0",
+        "unist-util-stringify-position": "^4.0.0",
+        "unist-util-visit": "^5.0.0",
+        "vfile": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/@mdx-js/mdx/node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
     "node_modules/@oslojs/encoding": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@@ -2601,6 +2725,15 @@
       "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
       "license": "MIT"
     },
+    "node_modules/@types/estree-jsx": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+      "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "*"
+      }
+    },
     "node_modules/@types/hast": {
       "version": "3.0.4",
       "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -2619,6 +2752,12 @@
         "@types/unist": "*"
       }
     },
+    "node_modules/@types/mdx": {
+      "version": "2.0.13",
+      "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz",
+      "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==",
+      "license": "MIT"
+    },
     "node_modules/@types/ms": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -2867,6 +3006,27 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/acorn": {
+      "version": "8.16.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+      "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+      "license": "MIT",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
     "node_modules/ajv": {
       "version": "8.18.0",
       "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
@@ -2985,6 +3145,15 @@
         "node": ">=12"
       }
     },
+    "node_modules/astring": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz",
+      "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==",
+      "license": "MIT",
+      "bin": {
+        "astring": "bin/astring"
+      }
+    },
     "node_modules/astro": {
       "version": "6.1.5",
       "resolved": "https://registry.npmjs.org/astro/-/astro-6.1.5.tgz",
@@ -3247,6 +3416,16 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/character-reference-invalid": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+      "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/chokidar": {
       "version": "4.0.3",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -3302,6 +3481,16 @@
         "node": ">=6"
       }
     },
+    "node_modules/collapse-white-space": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz",
+      "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/color-convert": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3698,6 +3887,38 @@
       "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
       "license": "MIT"
     },
+    "node_modules/esast-util-from-estree": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz",
+      "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree-jsx": "^1.0.0",
+        "devlop": "^1.0.0",
+        "estree-util-visit": "^2.0.0",
+        "unist-util-position-from-estree": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/esast-util-from-js": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz",
+      "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree-jsx": "^1.0.0",
+        "acorn": "^8.0.0",
+        "esast-util-from-estree": "^2.0.0",
+        "vfile-message": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/esbuild": {
       "version": "0.27.7",
       "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
@@ -3760,6 +3981,97 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/estree-util-attach-comments": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz",
+      "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/estree-util-build-jsx": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz",
+      "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree-jsx": "^1.0.0",
+        "devlop": "^1.0.0",
+        "estree-util-is-identifier-name": "^3.0.0",
+        "estree-walker": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/estree-util-build-jsx/node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/estree-util-is-identifier-name": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+      "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/estree-util-scope": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz",
+      "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "devlop": "^1.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/estree-util-to-js": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz",
+      "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree-jsx": "^1.0.0",
+        "astring": "^1.8.0",
+        "source-map": "^0.7.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/estree-util-visit": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz",
+      "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree-jsx": "^1.0.0",
+        "@types/unist": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/estree-walker": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -4034,6 +4346,34 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/hast-util-to-estree": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz",
+      "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "@types/estree-jsx": "^1.0.0",
+        "@types/hast": "^3.0.0",
+        "comma-separated-tokens": "^2.0.0",
+        "devlop": "^1.0.0",
+        "estree-util-attach-comments": "^3.0.0",
+        "estree-util-is-identifier-name": "^3.0.0",
+        "hast-util-whitespace": "^3.0.0",
+        "mdast-util-mdx-expression": "^2.0.0",
+        "mdast-util-mdx-jsx": "^3.0.0",
+        "mdast-util-mdxjs-esm": "^2.0.0",
+        "property-information": "^7.0.0",
+        "space-separated-tokens": "^2.0.0",
+        "style-to-js": "^1.0.0",
+        "unist-util-position": "^5.0.0",
+        "zwitch": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/hast-util-to-html": {
       "version": "9.0.5",
       "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
@@ -4057,6 +4397,33 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/hast-util-to-jsx-runtime": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+      "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "@types/hast": "^3.0.0",
+        "@types/unist": "^3.0.0",
+        "comma-separated-tokens": "^2.0.0",
+        "devlop": "^1.0.0",
+        "estree-util-is-identifier-name": "^3.0.0",
+        "hast-util-whitespace": "^3.0.0",
+        "mdast-util-mdx-expression": "^2.0.0",
+        "mdast-util-mdx-jsx": "^3.0.0",
+        "mdast-util-mdxjs-esm": "^2.0.0",
+        "property-information": "^7.0.0",
+        "space-separated-tokens": "^2.0.0",
+        "style-to-js": "^1.0.0",
+        "unist-util-position": "^5.0.0",
+        "vfile-message": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/hast-util-to-parse5": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
@@ -4166,6 +4533,12 @@
       "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
       "license": "BSD-2-Clause"
     },
+    "node_modules/inline-style-parser": {
+      "version": "0.2.7",
+      "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+      "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+      "license": "MIT"
+    },
     "node_modules/iron-webcrypto": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
@@ -4175,31 +4548,75 @@
         "url": "https://github.com/sponsors/brc-dd"
       }
     },
-    "node_modules/is-docker": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
-      "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+    "node_modules/is-alphabetical": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+      "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
       "license": "MIT",
-      "bin": {
-        "is-docker": "cli.js"
-      },
-      "engines": {
-        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
-      },
       "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
       }
     },
-    "node_modules/is-fullwidth-code-point": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
-      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-      "dev": true,
+    "node_modules/is-alphanumerical": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+      "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
       "license": "MIT",
-      "engines": {
+      "dependencies": {
+        "is-alphabetical": "^2.0.0",
+        "is-decimal": "^2.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/is-decimal": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+      "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/is-docker": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+      "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+      "license": "MIT",
+      "bin": {
+        "is-docker": "cli.js"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
         "node": ">=8"
       }
     },
+    "node_modules/is-hexadecimal": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+      "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/is-inside-container": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
@@ -4613,6 +5030,18 @@
         "source-map-js": "^1.2.1"
       }
     },
+    "node_modules/markdown-extensions": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz",
+      "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/markdown-table": {
       "version": "3.0.4",
       "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -4779,6 +5208,83 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/mdast-util-mdx": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz",
+      "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==",
+      "license": "MIT",
+      "dependencies": {
+        "mdast-util-from-markdown": "^2.0.0",
+        "mdast-util-mdx-expression": "^2.0.0",
+        "mdast-util-mdx-jsx": "^3.0.0",
+        "mdast-util-mdxjs-esm": "^2.0.0",
+        "mdast-util-to-markdown": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-mdx-expression": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+      "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree-jsx": "^1.0.0",
+        "@types/hast": "^3.0.0",
+        "@types/mdast": "^4.0.0",
+        "devlop": "^1.0.0",
+        "mdast-util-from-markdown": "^2.0.0",
+        "mdast-util-to-markdown": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-mdx-jsx": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+      "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree-jsx": "^1.0.0",
+        "@types/hast": "^3.0.0",
+        "@types/mdast": "^4.0.0",
+        "@types/unist": "^3.0.0",
+        "ccount": "^2.0.0",
+        "devlop": "^1.1.0",
+        "mdast-util-from-markdown": "^2.0.0",
+        "mdast-util-to-markdown": "^2.0.0",
+        "parse-entities": "^4.0.0",
+        "stringify-entities": "^4.0.0",
+        "unist-util-stringify-position": "^4.0.0",
+        "vfile-message": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-mdxjs-esm": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+      "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree-jsx": "^1.0.0",
+        "@types/hast": "^3.0.0",
+        "@types/mdast": "^4.0.0",
+        "devlop": "^1.0.0",
+        "mdast-util-from-markdown": "^2.0.0",
+        "mdast-util-to-markdown": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/mdast-util-phrasing": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
@@ -5044,6 +5550,108 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/micromark-extension-mdx-expression": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz",
+      "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "devlop": "^1.0.0",
+        "micromark-factory-mdx-expression": "^2.0.0",
+        "micromark-factory-space": "^2.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-events-to-acorn": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-extension-mdx-jsx": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz",
+      "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "devlop": "^1.0.0",
+        "estree-util-is-identifier-name": "^3.0.0",
+        "micromark-factory-mdx-expression": "^2.0.0",
+        "micromark-factory-space": "^2.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-events-to-acorn": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0",
+        "vfile-message": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-mdx-md": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz",
+      "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-types": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-mdxjs": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz",
+      "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==",
+      "license": "MIT",
+      "dependencies": {
+        "acorn": "^8.0.0",
+        "acorn-jsx": "^5.0.0",
+        "micromark-extension-mdx-expression": "^3.0.0",
+        "micromark-extension-mdx-jsx": "^3.0.0",
+        "micromark-extension-mdx-md": "^2.0.0",
+        "micromark-extension-mdxjs-esm": "^3.0.0",
+        "micromark-util-combine-extensions": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-mdxjs-esm": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz",
+      "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "devlop": "^1.0.0",
+        "micromark-core-commonmark": "^2.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-events-to-acorn": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0",
+        "unist-util-position-from-estree": "^2.0.0",
+        "vfile-message": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/micromark-factory-destination": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -5087,6 +5695,33 @@
         "micromark-util-types": "^2.0.0"
       }
     },
+    "node_modules/micromark-factory-mdx-expression": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz",
+      "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "devlop": "^1.0.0",
+        "micromark-factory-space": "^2.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-events-to-acorn": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0",
+        "unist-util-position-from-estree": "^2.0.0",
+        "vfile-message": "^4.0.0"
+      }
+    },
     "node_modules/micromark-factory-space": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
@@ -5288,6 +5923,31 @@
       ],
       "license": "MIT"
     },
+    "node_modules/micromark-util-events-to-acorn": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz",
+      "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "@types/unist": "^3.0.0",
+        "devlop": "^1.0.0",
+        "estree-util-visit": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0",
+        "vfile-message": "^4.0.0"
+      }
+    },
     "node_modules/micromark-util-html-tag-name": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
@@ -5641,6 +6301,31 @@
       "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
       "license": "MIT"
     },
+    "node_modules/parse-entities": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+      "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^2.0.0",
+        "character-entities-legacy": "^3.0.0",
+        "character-reference-invalid": "^2.0.0",
+        "decode-named-character-reference": "^1.0.0",
+        "is-alphanumerical": "^2.0.0",
+        "is-decimal": "^2.0.0",
+        "is-hexadecimal": "^2.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/parse-entities/node_modules/@types/unist": {
+      "version": "2.0.11",
+      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+      "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+      "license": "MIT"
+    },
     "node_modules/parse-latin": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz",
@@ -5869,6 +6554,73 @@
         "url": "https://paulmillr.com/funding/"
       }
     },
+    "node_modules/recma-build-jsx": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz",
+      "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "estree-util-build-jsx": "^3.0.0",
+        "vfile": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/recma-jsx": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz",
+      "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==",
+      "license": "MIT",
+      "dependencies": {
+        "acorn-jsx": "^5.0.0",
+        "estree-util-to-js": "^2.0.0",
+        "recma-parse": "^1.0.0",
+        "recma-stringify": "^1.0.0",
+        "unified": "^11.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      },
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/recma-parse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz",
+      "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "esast-util-from-js": "^2.0.0",
+        "unified": "^11.0.0",
+        "vfile": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/recma-stringify": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz",
+      "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "estree-util-to-js": "^2.0.0",
+        "unified": "^11.0.0",
+        "vfile": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/regex": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
@@ -5959,6 +6711,21 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/rehype-recma": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz",
+      "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "@types/hast": "^3.0.0",
+        "hast-util-to-estree": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/rehype-stringify": {
       "version": "10.0.1",
       "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz",
@@ -5992,6 +6759,20 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/remark-mdx": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz",
+      "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==",
+      "license": "MIT",
+      "dependencies": {
+        "mdast-util-mdx": "^3.0.0",
+        "micromark-extension-mdxjs": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/remark-parse": {
       "version": "11.0.0",
       "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -6407,6 +7188,24 @@
         "node": ">=8"
       }
     },
+    "node_modules/style-to-js": {
+      "version": "1.1.21",
+      "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+      "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+      "license": "MIT",
+      "dependencies": {
+        "style-to-object": "1.0.14"
+      }
+    },
+    "node_modules/style-to-object": {
+      "version": "1.0.14",
+      "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+      "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+      "license": "MIT",
+      "dependencies": {
+        "inline-style-parser": "0.2.7"
+      }
+    },
     "node_modules/supports-color": {
       "version": "10.2.2",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
@@ -6731,6 +7530,19 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/unist-util-position-from-estree": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz",
+      "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/unist-util-remove-position": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
diff --git a/package.json b/package.json
index 69fb00f..df4cedb 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
   },
   "dependencies": {
     "@astrojs/cloudflare": "^13.1.3",
+    "@astrojs/mdx": "^5.0.6",
     "@astrojs/preact": "^5.0.2",
     "@tailwindcss/vite": "^4.2.2",
     "astro": "^6.0.8",
diff --git a/src/components/docs/Callout.astro b/src/components/docs/Callout.astro
new file mode 100644
index 0000000..f1e32e8
--- /dev/null
+++ b/src/components/docs/Callout.astro
@@ -0,0 +1,60 @@
+---
+type Variant = 'info' | 'warning';
+
+interface Props {
+  title: string;
+  variant?: Variant;
+}
+
+const { title, variant = 'info' } = Astro.props;
+
+const styles: Record = {
+  info: {
+    border: 'border-neutral-800/60',
+    bg: 'bg-[#0a0a0a]',
+    dot: 'bg-violet-500 shadow-[0_0_8px_rgba(139,92,246,0.5)]',
+  },
+  warning: {
+    border: 'border-amber-900/40',
+    bg: 'bg-amber-950/20',
+    dot: 'bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.5)]',
+  },
+};
+
+const s = styles[variant];
+---
+
+
+
+ +
+

{title}

+
+ +
+
+
+
+ + diff --git a/src/components/docs/EndpointGrid.astro b/src/components/docs/EndpointGrid.astro new file mode 100644 index 0000000..419940b --- /dev/null +++ b/src/components/docs/EndpointGrid.astro @@ -0,0 +1,28 @@ +--- +interface Endpoint { + href: string; + title: string; + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + path: string; +} + +interface Props { + items: Endpoint[]; +} + +const { items } = Astro.props; +--- + +
+ {items.map((ep) => ( + +

{ep.title}

+

+ {ep.method} {ep.path} +

+
+ ))} +
diff --git a/src/components/docs/FieldList.astro b/src/components/docs/FieldList.astro new file mode 100644 index 0000000..4a60a65 --- /dev/null +++ b/src/components/docs/FieldList.astro @@ -0,0 +1,38 @@ +--- +interface Field { + name: string; + type: string; + description: string; +} + +interface Props { + fields: Field[]; +} + +const { fields } = Astro.props; + +const isRequired = (t: string) => /required/i.test(t); +--- + +
+
+ {fields.map((f, i) => ( +
+
+
{f.name}
+ + {f.type} + +
+
+ +
+
+ ))} +
+
diff --git a/src/components/docs/LimitationsCard.astro b/src/components/docs/LimitationsCard.astro new file mode 100644 index 0000000..f9b504b --- /dev/null +++ b/src/components/docs/LimitationsCard.astro @@ -0,0 +1,27 @@ +--- +interface Limitation { + title: string; + body: string; +} + +interface Props { + title?: string; + items: Limitation[]; +} + +const { title = 'limitaciones conocidas', items } = Astro.props; +--- + +
+

+ {title} +

+
+ {items.map((item) => ( +
+
{item.title}
+
+
+ ))} +
+
diff --git a/src/components/docs/ModelCard.astro b/src/components/docs/ModelCard.astro new file mode 100644 index 0000000..ad39560 --- /dev/null +++ b/src/components/docs/ModelCard.astro @@ -0,0 +1,53 @@ +--- +interface Spec { + label: string; + value: string; +} + +interface Props { + id: string; + name: string; + tag?: string; + leftLabel: string; + rightLabel: string; + description: string; + specs: Spec[]; + items: string[]; +} + +const { id, name, tag, leftLabel, rightLabel, description, specs, items } = Astro.props; +const heading = tag ? `${name} - ${tag}` : name; +--- + +

{heading}

+ +
+
+

+ {leftLabel} +

+

{description}

+
+ {specs.map((spec) => ( +
+
{spec.label}
+
+
+ ))} +
+
+ +
+

+ {rightLabel} +

+
    + {items.map((item) => ( +
  • + + +
  • + ))} +
+
+
diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..80ab200 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,14 @@ +import { defineCollection, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +const docs = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/docs' }), + schema: z.object({ + title: z.string(), + description: z.string(), + order: z.number().int().min(0), + locale: z.string().default('es'), + }), +}); + +export const collections = { docs }; diff --git a/src/content/docs/agents.md b/src/content/docs/agents.md new file mode 100644 index 0000000..f8db678 --- /dev/null +++ b/src/content/docs/agents.md @@ -0,0 +1,112 @@ +--- +title: Agents +description: "Despliega agentes de IA en una microVM aislada con QEMU: Hermes, terminal web, carga de ficheros y observabilidad." +order: 5 +--- + +# Agents. + +NaN Cloud te deja desplegar agentes de IA en tu propia **microVM**: una máquina virtual ligera con QEMU + KVM, su propio kernel, su propio filesystem y acceso root completo. Aislada del host y del resto de miembros. El primer tipo de agente disponible es **Hermes**. + +## Arquitectura + +Cada agente corre dentro de su propia microVM con QEMU. En lugar de compartir el kernel del host (como un container normal), arranca con su propio kernel Linux. La VM monta un disco ext4 de 20 GiB sobre un volumen en modo block, persistente. Todo lo que haces dentro —`apt install`, `pip install`, edits en `/etc`, ficheros que subas, sesiones de bash— vive en ese disco y sobrevive a reinicios. + +El shutdown es *graceful*: cuando reinicies o borres el agente, el sistema fuerza un `sync` y espera a que el journal de ext4 termine de vaciar antes de matar la VM. Sin corrupciones. + +## Hermes + +Hermes es un agente de IA conversacional que se conecta a Telegram. Puedes hablar con él, pedirle que gestione notas, ejecute comandos en su entorno, genere sitios web y mucho más. + +### 1. Crear un bot de Telegram + +Necesitas un bot de Telegram. Abre Telegram, busca [@BotFather](https://core.telegram.org/bots/tutorial#obtain-your-bot-token) y sigue las instrucciones para crear un bot nuevo. Copia el token que te da. + +### 2. Crear el agente + +Ve a [cloud.nan.builders/agents/new](https://cloud.nan.builders/agents/new) y rellena: nombre, tipo (Hermes), el token de Telegram, modelo y opcionalmente un *soul* (system prompt) que defina la personalidad de tu agente. + +![Formulario de creación de agente](/docs/agents/create-agent-form.png) + +### 3. Esperar a que esté Running + +Tras crear el agente espera ~30 segundos a que el microVM arranque, formatee el disco la primera vez (`mkfs.ext4`) y siembre el sistema de ficheros. El estado pasa a `Running` y Hermes a `Ready`. + +### 4. Hablar con tu agente + +Busca tu bot en Telegram y envíale un mensaje. Hermes responderá usando el modelo que hayas configurado. + +![Conversación con Hermes en Telegram](/docs/agents/telegram-hermes-chat.jpg) + +> **Tu agente está listo.** +> Con estos 4 pasos ya tienes a Hermes funcionando. Lo que viene a continuación son funcionalidades adicionales del panel del agente: terminal web, subida de ficheros, observabilidad, exposición HTTP, Hermes UI y gestión de variables de entorno. + +## Console — terminal web + +La pestaña **Console** abre un terminal interactivo (`bash --login`) dentro de tu microVM, sin que tengas que configurar SSH. El stream va sobre WebSocket con xterm.js: resize automático cuando ajustas el panel, status pill arriba a la derecha y botón de reconexión si la sesión se cae. + +Casos de uso típicos: + +- Instalar paquetes: `apt update && apt install -y nginx` +- Inspeccionar logs internos del agente +- Mover ficheros que hayas subido a su ubicación final +- Tirar de `htop`, `df -h`, `journalctl`, etc. + +> **Límites operativos** +> 1 sesión simultánea por agente · idle timeout 10 min · duración máxima 30 min por sesión. + +## Files — subida de ficheros + +La pestaña **Files** permite subir ficheros al microVM con drag-and-drop o picker. Multi-fichero, cola secuencial, progress bar en vivo con MiB/s. Los archivos aterrizan en `/persist/uploads/` y desde ahí los puedes mover con la Console. + +- Tamaño máximo: **200 MiB** por fichero. +- Transporte: WebSocket con chunks de 256 KiB y backpressure end-to-end. +- Filename sanitizado server-side (sin path traversal). +- Listado en vivo de lo ya subido (refresca cada 5 s). + +## Observability + +La pestaña **Observability** agrupa tres sub-pestañas: + +- **Logs** — stream en vivo de stdout/stderr del agente vía WebSocket. Buffer de las últimas 500 líneas en el cliente. +- **Events** — eventos de Kubernetes del Pod (BackOff, Scheduled, Pulled, Killing...) con tipo, razón, mensaje, edad y contador. Auto-refresh cada 15 s. +- **Metrics** — uso real de CPU, RAM y disco contra los límites configurados. CPU/RAM vía Prometheus (kubelet-cadvisor), disco vía `df` dentro del microVM (el filesystem es block-mode, kubelet no lo ve). Refresca cada 10 s. + +## Web — exposición pública + +La pestaña **Web** tiene dos sub-pestañas para sacar servicios HTTP del agente: + +### HTTP + +Cualquier servicio que tu agente sirva por HTTP (nginx, una API, un static-site) lo puedes exponer públicamente. Por ejemplo, pídele a Hermes que instale nginx con un HTML personalizado: + +![Pidiendo a Hermes que instale nginx con un HTML personalizado](/docs/agents/telegram-nginx-setup.jpg) + +En la pestaña **Web → HTTP** pulsa **Enable HTTP**. Por defecto se expone el puerto `80`; si tu servicio escucha en otro puerto, indícalo en **Container Port**. La plataforma genera una URL pública en `*.apps.nan.builders`. + +![Sitio web generado por Hermes visible desde la URL pública](/docs/agents/http-result.png) + +### Hermes UI + +Hermes incluye una UI web ligera ([nesquena/hermes-webui](https://github.com/nesquena/hermes-webui)) que se ejecuta siempre dentro del agente. Desde **Web → Hermes UI** puedes habilitar acceso externo: la plataforma genera una URL del estilo `webui--.apps.nan.builders` protegida por una contraseña per-agent que aparece en el panel. + +## Variables de entorno + +La pestaña **Env** permite añadir, editar y borrar variables de entorno del agente sin tocar el Deployment. Útil para inyectar API keys de terceros, configurar comportamiento de Hermes, etc. + +Dos variables son **protegidas** (sólo edit, no delete): `OPENAI_API_KEY` (tu key del cluster, gestionada por la plataforma) y `TELEGRAM_BOT_TOKEN`. El resto son creación / edición / borrado libre. + +## Recursos y límites + +Cada microVM se aprovisiona con: + +| Recurso | Request | Limit | +|---|---|---| +| CPU | 200m | 1 vCPU | +| RAM | 512 Mi | 2 GiB | +| Disco | — | 20 GiB (PVC block-mode) | + +CPU y RAM son los límites máximos del microVM; el uso real suele estar muy por debajo. El disco es persistente — todo lo que instales o modifiques (paquetes, archivos, configuraciones) se conserva entre reinicios. Si el disco se llena (90%+), libéralo desde la Console (`du -sh /persist/*`). + +> **Límite actual** +> Actualmente cada miembro puede desplegar **1 agente microVM**. Este límite se ampliará en futuras versiones. diff --git a/src/content/docs/api.mdx b/src/content/docs/api.mdx new file mode 100644 index 0000000..80b50c5 --- /dev/null +++ b/src/content/docs/api.mdx @@ -0,0 +1,653 @@ +--- +title: API +description: Referencia de los endpoints públicos de la API. Compatible con OpenAI. +order: 2 +--- + +import Callout from '../../components/docs/Callout.astro'; +import EndpointGrid from '../../components/docs/EndpointGrid.astro'; +import LimitationsCard from '../../components/docs/LimitationsCard.astro'; +import RateLimits from '../../components/docs/RateLimits.astro'; + +# Referencia de la API. + +Nuestra API es compatible con OpenAI: cualquier cliente o SDK que acepte un `base URL` + `API key` funciona sin cambios. La base URL es `https://api.nan.builders/v1` y la autenticación es vía `Bearer token`. Para obtener tu key, consulta [Empezar](/docs/getting-started). + + + Si usas el servicio enterprise de Helmcode recuerda que la URL de la API es api.helmcode.com. El resto de los endpoints es idéntico. + + +## Endpoints + +Listado de los endpoints disponibles. Cada uno enlaza a su sección con `request`, `response` y un ejemplo en `curl`. + + + +## Autenticación + +Todas las peticiones requieren el header `Authorization: Bearer `. La key es personal e intransferible — consulta [Empezar](/docs/getting-started) para obtener la tuya. + +```curl +curl https://api.nan.builders/v1/models \ + -H "Authorization: Bearer sk-tu-key-aqui" +``` + +

GET /v1/models

+ +Devuelve la lista de modelos disponibles para tu key. Modelos publicados: `deepseek-v4-flash`, `qwen3.6`, `gemma4`, `qwen3-embedding`, `kokoro`, `whisper`. + +### Request + +Sin body. Solo el header de autenticación. + +### Response + +```json +{ + "object": "list", + "data": [ + { + "id": "qwen3.6", + "object": "model", + "created": 1677610602, + "owned_by": "openai" + } + ] +} +``` + +### Ejemplo + +```curl +curl https://api.nan.builders/v1/models \ + -H "Authorization: Bearer sk-tu-key-aqui" +``` + +

POST /v1/chat/completions

+ +El endpoint principal de chat. Compatible con OpenAI Chat Completions. Modelos compatibles: `deepseek-v4-flash`, `qwen3.6` y `gemma4`. + +reasoning_content en el message).', + }, + { + title: 'gemma4', + body: 'Chat, streaming, vision (image input), reasoning (opt-in).', + }, + ]} +/> + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `model` | string · required | `qwen3.6` o `gemma4`. | +| `messages` | array · required | Lista de mensajes `{ role, content }`. `content` puede ser string o un array de partes `[{type:"text",text}, {type:"image_url",image_url:{url}}]` para input multimodal. | +| `max_tokens` | integer · optional | Tope de tokens generados. | +| `stream` | boolean · optional | Default `false`. Si `true`, la respuesta llega como SSE. | +| `tools` | array · optional | Function calling estándar OpenAI: `{type:"function",function:{name,description,parameters}}`. Validado solo con `qwen3.6`. | +| `tool_choice` | string \| object · optional | Controla qué tool puede invocar el modelo. Estándar OpenAI. | +| `temperature` | number · optional | Default `0.6`. | +| `top_p` | number · optional | Default `0.95`. | + +### Response + +Respuesta sin streaming. `finish_reason` puede ser `stop`, `length` o `tool_calls`. + +```json +{ + "id": "chatcmpl-...", + "created": 1778258163, + "model": "qwen3.6", + "object": "chat.completion", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "role": "assistant", + "content": "...", + "reasoning_content": "..." + } + } + ], + "usage": { + "completion_tokens": 20, + "prompt_tokens": 17, + "total_tokens": 37 + } +} +``` + +El campo `reasoning_content` se incluye solo cuando se usa `qwen3.6`. Es opcional ignorarlo. + +### Ejemplo + +```curl +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "messages": [{"role": "user", "content": "Hola"}], + "max_tokens": 200 + }' +``` + +### Streaming + +Con `stream: true`, la respuesta se entrega como Server-Sent Events. Cada chunk es `data: {...}\n\n` con el delta en `choices[0].delta.content`. El stream termina con `data: [DONE]`. + +```curl +curl https://api.nan.builders/v1/chat/completions \ + -N \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "messages": [{"role": "user", "content": "Cuéntame un chiste corto"}], + "stream": true + }' +``` + +### Tool calling + +`qwen3.6` soporta function calling estándar OpenAI. Cuando el modelo decide invocar una tool, la respuesta incluye `choices[0].message.tool_calls` con `{id, type:"function", function:{name, arguments}}` y `finish_reason: "tool_calls"`. + +```curl +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "messages": [{"role": "user", "content": "¿Qué tiempo hace en Madrid?"}], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Obtiene el tiempo actual de una ciudad", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + } + } + } + ] + }' +``` + +### Vision + +Tanto `qwen3.6` como `gemma4` aceptan input multimodal. El campo `content` del mensaje pasa de string a un array de partes de tipo `text` y/o `image_url`. + +```curl +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "¿Qué hay en esta imagen?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/foto.jpg"}} + ] + }] + }' +``` + +### Structured outputs + +Los modelos de chat aceptan el campo `response_format` estándar de OpenAI para forzar respuestas JSON válidas. Soportamos los dos modos: + +- **json_object**: Garantiza que la respuesta sea JSON sintácticamente válido. No impone estructura. +- **json_schema**: Restringe la salida a un JSON Schema concreto. Con `strict: true` el modelo no puede emitir campos fuera del schema. + +Funciona en `qwen3.6` y `gemma4`. + +**json_object:** + +```curl +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "messages": [ + {"role": "user", "content": "Devuelve un objeto user con name=Alice y age=30."} + ], + "response_format": { "type": "json_object" } + }' +``` + +**json_schema (strict):** + +```curl +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "messages": [ + {"role": "user", "content": "Alice, 30 años."} + ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "user", + "strict": true, + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" } + }, + "required": ["name", "age"], + "additionalProperties": false + } + } + } + }' +``` + +Con el SDK de `openai` en Python: + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-tu-key-aqui", + base_url="https://api.nan.builders/v1" +) + +response = client.chat.completions.create( + model="qwen3.6", + messages=[{"role": "user", "content": "Alice, 30 años."}], + response_format={ + "type": "json_schema", + "json_schema": { + "name": "user", + "strict": True, + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name", "age"], + "additionalProperties": False + } + } + } +) + +import json +data = json.loads(response.choices[0].message.content) +print(data["name"], data["age"]) +``` + +### Reasoning + +Ambos modelos soportan reasoning. El toggle se controla con el campo `chat_template_kwargs.enable_thinking` en el body del request. Cuando está activo, la respuesta incluye `reasoning_content` en `message` con la cadena de razonamiento del modelo. + +| Modelo | Reasoning | +|---|---| +| `qwen3.6` | activo por defecto | +| `gemma4` | desactivado por defecto | + +```curl +curl https://api.nan.builders/v1/chat/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gemma4", + "messages": [{"role": "user", "content": "Qué es 2+2?"}], + "chat_template_kwargs": { "enable_thinking": true } + }' +``` + +Para desactivarlo en `qwen3.6` pasa `{ "enable_thinking": false }`. + +En SDKs como `openai` de Python o Node, este campo va dentro de `extra_body`: + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-tu-key-aqui", + base_url="https://api.nan.builders/v1" +) + +response = client.chat.completions.create( + model="gemma4", + messages=[{"role": "user", "content": "Qué es 2+2?"}], + extra_body={"chat_template_kwargs": {"enable_thinking": True}} +) + +print(response.choices[0].message.reasoning_content) +``` + +

POST /v1/completions

+ +Endpoint legacy de OpenAI para text completion. Modelo compatible: `qwen3.6`. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `model` | string · required | `qwen3.6`. | +| `prompt` | string · required | El prompt a completar. | +| `max_tokens` | integer · optional | Tope de tokens generados. | +| `temperature` | number · optional | Default `0.6`. | +| `top_p` | number · optional | Default `0.95`. | +| `stream` | boolean · optional | Default `false`. | + +### Response + +```json +{ + "id": "cmpl-...", + "object": "text_completion", + "created": 1778258166, + "model": "qwen3.6", + "choices": [ + { + "text": "...", + "index": 0, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "completion_tokens": 10, + "prompt_tokens": 5, + "total_tokens": 15 + } +} +``` + +### Ejemplo + +```curl +curl https://api.nan.builders/v1/completions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "prompt": "The capital of France is", + "max_tokens": 10 + }' +``` + +### Notas + +Endpoint legacy de OpenAI. Para conversaciones, usa [/v1/chat/completions](#chat-completions). + +

POST /v1/embeddings

+ +Genera embeddings vectoriales. Modelo compatible: `qwen3-embedding`. Vectores de **4096 dimensiones**. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `model` | string · required | `qwen3-embedding`. | +| `input` | string \| array · required | Texto único o array de strings a embeddear. | +| `encoding_format` | string · optional | `"float"` (default) o `"base64"`. | + +### Response + +```json +{ + "object": "list", + "model": "qwen3-embedding", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": [0.0210, 0.0105, -0.0204, "..."] + } + ], + "usage": { + "prompt_tokens": 3, + "total_tokens": 3 + } +} +``` + +### Ejemplo + +```curl +curl https://api.nan.builders/v1/embeddings \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3-embedding", + "input": ["Hola mundo", "Hello world"] + }' +``` + +

POST /v1/audio/speech

+ +Sintetiza audio a partir de texto (text-to-speech). Modelo compatible: `kokoro`. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `model` | string · required | `kokoro`. | +| `input` | string · required | Texto a sintetizar. | +| `voice` | string · required | Voz a usar. Algunas opciones: `af_heart` (English female), `ef_dora` (Spanish female), `em_alex` (Spanish male). [Ver listado completo](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md). | +| `response_format` | string · optional | Formato del audio devuelto. Validados: `mp3` (default), `wav`, `flac`, `aac`, `pcm`, `opus`. | +| `speed` | number · optional | Default `1.0`. | + +### Response + +Archivo binario de audio en el formato pedido (sin envoltorio JSON). + +### Ejemplo + +```curl +curl https://api.nan.builders/v1/audio/speech \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "kokoro", + "input": "Bienvenido a NaN.", + "voice": "ef_dora", + "response_format": "mp3" + }' \ + -o speech.mp3 +``` + +

POST /v1/audio/transcriptions

+ +Transcribe audio a texto (speech-to-text). Modelo compatible: `whisper`. La petición es `multipart/form-data`. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `file` | file · required | Archivo de audio a transcribir. | +| `model` | string · required | `whisper`. | +| `language` | string · optional | Código ISO-639-1 (ej. `es`, `en`). Si no se pasa, se detecta automáticamente. | +| `response_format` | string · optional | Validados: `json` (default) y `verbose_json`. Otros valores funcionan pero devuelven el contenido envuelto en JSON; recomendamos solo estos dos. | +| `timestamp_granularities[]` | string · optional | Solo con `verbose_json`. Valores: `word` (timestamps por palabra) o `segment` (default). | +| `temperature` | number · optional | Sampling temperature. | + +### Response + +Ejemplo con `response_format=verbose_json`: + +```json +{ + "text": "Hola, esto es una prueba.", + "language": "es", + "task": "transcribe", + "duration": 1.728, + "segments": [ + { + "id": 1, + "start": 0.0, + "end": 1.4, + "text": " Hola, esto es una prueba.", + "tokens": [50365, 22637, "..."], + "avg_logprob": -0.059, + "compression_ratio": 0.806, + "no_speech_prob": 0.044, + "temperature": 0.0 + } + ], + "words": null +} +``` + +Si pasas `timestamp_granularities[]=word`, el campo `words` se llena con `[{word, start, end, probability}]`. + +### Ejemplo + +```curl +curl https://api.nan.builders/v1/audio/transcriptions \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -F "model=whisper" \ + -F "file=@grabacion.mp3" \ + -F "language=es" \ + -F "response_format=verbose_json" +``` + +### Limitaciones + + 2 min pueden devolver timeout 524', + body: 'Recomendamos dividir en segmentos de ≤ 2 min.', + }, + { + title: 'Formatos recomendados', + body: 'OGG/Opus y MP3 — mejor compresión, misma calidad de transcripción.', + }, + ]} +/> + +

POST /v1/responses

+ +Endpoint Responses estilo OpenAI. Modelos compatibles: `qwen3.6` y `gemma4`. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `model` | string · required | `qwen3.6` o `gemma4`. | +| `input` | string \| array · required | Texto único o array de mensajes en formato OpenAI Responses. | +| `max_output_tokens` | integer · optional | Default `65536` en `qwen3.6`. | +| `temperature` | number · optional | Default `0.6`. | +| `top_p` | number · optional | Default `0.95`. | +| `instructions` | string · optional | Instrucciones de sistema. | + +### Response + +El array `output` puede contener bloques de tipo `reasoning` (solo `qwen3.6`) y `message`. + +```json +{ + "id": "resp_...", + "created_at": 1778258181, + "model": "qwen3.6", + "object": "response", + "status": "completed", + "output": [ + { + "id": "rs_...", + "type": "reasoning", + "summary": [], + "content": [ + { "type": "reasoning_text", "text": "..." } + ] + }, + { + "id": "msg_...", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { "type": "output_text", "text": "Hola.", "annotations": [] } + ] + } + ], + "usage": { + "input_tokens": 17, + "output_tokens": 118, + "total_tokens": 135 + } +} +``` + +### Ejemplo + +```curl +curl https://api.nan.builders/v1/responses \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.6", + "input": "Hola, ¿cómo estás?" + }' +``` + +### Notas + +El streaming en este endpoint actualmente entrega un único evento `response.completed` al final, no chunks incrementales. Para streaming token-a-token usa [/v1/chat/completions](#chat-completions) con `stream: true`. + +## Errores + +Los errores siguen el formato estándar de OpenAI: HTTP status no-2xx con un body JSON describiendo el problema. + +```json +{ + "error": { + "message": "...", + "type": null, + "param": null, + "code": "..." + } +} +``` + +| Código | Descripción | +|---|---| +| 401 | Header `Authorization` inválido o ausente. | +| 404 | Modelo no existe (campo `model`). | +| 429 | Rate limit excedido — `rpm_limit` o `max_parallel_requests`. | +| 500 | Error interno (incluye errores upstream del modelo). | +| 524 | Timeout (típico con audios grandes en [/v1/audio/transcriptions](#audio-transcriptions)). | + +## Rate limits + + diff --git a/src/content/docs/apps.md b/src/content/docs/apps.md new file mode 100644 index 0000000..f97cdc7 --- /dev/null +++ b/src/content/docs/apps.md @@ -0,0 +1,60 @@ +--- +title: Apps +description: Despliega tus aplicaciones desde GitHub a NaN Cloud en minutos. +order: 6 +--- + +# Apps. + +NaN Cloud te permite **desplegar tus propias apps desde un repositorio de GitHub**: construimos tu imagen, la publicamos en un entorno aislado tuyo y la servimos detrás de un dominio público con HTTPS. Todo en un clic. + +> **Antes de empezar** +> Las Apps viven dentro de un **Space**: tu propio entorno con su cuota de recursos. Si tienes la suscripción de inferencia activa, recibes **un Space Basic gratis** incluido en tu membresía. Si no, puedes comprar uno desde [cloud.nan.builders/spaces](https://cloud.nan.builders/spaces). + +## 1. Crear un Space + +Entra en [cloud.nan.builders/spaces](https://cloud.nan.builders/spaces). Si eres miembro de inferencia verás un panel que te ofrece reclamar tu Space Basic gratis: elige un *slug* (entre 1 y 20 caracteres, minúsculas, sin espacios) y pulsa **Claim free Basic**. El slug se usará para construir los dominios públicos de tus apps, así que escógelo con cariño. + +![Reclamar un Space Basic gratuito](/docs/apps/01-claim-free-space.png) + +El Space se activa al instante. + +## 2. Crear una App dentro del Space + +Abre tu Space recién creado. Verás el resumen de recursos consumidos, el botón **Change plan** por si quieres subir de tier en algún momento, y la sección **Apps in this Space**. Pulsa **New App** para arrancar el formulario. + +![Crear una nueva App dentro del Space](/docs/apps/02-space-new-app.png) + +## 3. Conectar GitHub y configurar la build + +Conecta tu cuenta de GitHub autorizando la NaN Cloud GitHub App al repositorio que vas a desplegar (la primera vez te lleva al flujo oficial de instalación en github.com). Una vez conectado, selecciona el repo de la lista, elige la rama y dale un nombre a tu App. + +> **Requisito imprescindible: Dockerfile** +> Tu repositorio **debe contener un `Dockerfile`** en la raíz (o en el path que configures). Sin Dockerfile no podemos construir tu imagen y la app no se desplegará. Así tienes control total sobre el runtime, las dependencias y los procesos que arrancan dentro de tu app. + +Si tu app es un servicio HTTP (página web, API, panel admin, etc.), marca **Expose over HTTP** e indica el **puerto** en el que tu app escucha internamente. Por ejemplo, si arrancas con `node server.js` escuchando en `:8080`, pon `8080` aquí. Nosotros nos encargamos de publicarla en un dominio público con HTTPS. + +Si tu app es un proceso que no necesita ser accesible desde fuera (un worker, un cron, un consumer de cola...), desmarca *Expose over HTTP*: la app arrancará en modo worker, sin URL pública. + +![Formulario de creación de App: GitHub + Dockerfile + puerto](/docs/apps/03-new-app-form.png) + +El bloque **Environment variables** (opcional) te deja añadir variables tanto de tiempo de ejecución como de build. Y en **Advanced options** puedes ajustar réplicas, CPU/memoria y añadir almacenamiento persistente si tu app necesita guardar estado. + +Pulsa **Deploy**. En la pantalla de detalle de la App verás la build en directo. Tras el build, si todo ha ido bien, verás que el estado pasa a `Running`. + +## 4. Abrir tu App + +Cuando el estado sea `Running`, pulsa el botón **Open** arriba a la derecha. Te abre la URL pública de tu app en una pestaña nueva. + +![App en estado Running con botón Open](/docs/apps/04-app-running.png) + +Desde la misma pantalla tienes acceso a los logs de tu app en directo, sus eventos, métricas (CPU, memoria, disco), gestión de variables de entorno y un panel de ajustes para mutar rama, Dockerfile, puerto y recursos en caliente. + +## 5. Tu app, en producción + +Y eso es todo. Tu repositorio de GitHub está sirviendo tráfico real desde un dominio público con HTTPS, sobre infra nuestra. Cada `git push` a la rama configurada (con auto-deploy activado) dispara una nueva build automáticamente. + +![Ejemplo de App desplegada y servida](/docs/apps/05-app-example.png) + +> **Tu App está viva.** +> Con estos 5 pasos ya tienes tu app desplegada. Si necesitas escalar (más recursos, más réplicas, almacenamiento persistente, más Spaces para separar entornos dev/staging/prod), puedes hacerlo en cualquier momento desde el dashboard. Apps y Spaces están en **Beta** — si encuentras algún problema, repórtalo en `#support` en Discord. diff --git a/src/pages/docs/examples.astro b/src/content/docs/examples.md similarity index 50% rename from src/pages/docs/examples.astro rename to src/content/docs/examples.md index bf0883b..967df1a 100644 --- a/src/pages/docs/examples.astro +++ b/src/content/docs/examples.md @@ -1,37 +1,34 @@ --- -import Docs from '../../layouts/Docs.astro'; -import CodeBlock from '../../components/docs/CodeBlock.astro'; +title: Ejemplos +description: Code snippets para conectar a la API de NaN con Python, Node.js, curl y más. +order: 4 --- - -
-

- // examples -

-
+# Code snippets. -

Code snippets.

+Ejemplos para conectar a la API con diferentes lenguajes y herramientas. Usa `https://api.nan.builders/v1` como base URL y tu API key personal. -

Ejemplos para conectar a la API con diferentes lenguajes y herramientas. - Usa https://api.nan.builders/v1 - como base URL y tu API key personal.

+## modelo: qwen3.6 - -

modelo: qwen3.6

-

generación de texto y chat

+generación de texto y chat -

curl

- + }' +``` + +### python (openai) -

python (openai)

- -

- Instalar: pip install openai -

+ print(content, end="", flush=True) +``` + +Instalar: `pip install openai` + +### node.js (openai) -

node.js (openai)

- -

- Instalar: npm install openai -

+} +``` -

opencode.json (config)

- -

- Este es el config para conectar IDEs (Cursor, OpenCode) con qwen3.6 — el único modelo de chat. -

+} +``` + +Este es el config para conectar IDEs (Cursor, OpenCode) con qwen3.6 — el único modelo de chat. + +### .pi/agent/models.json (config) -

.pi/agent/models.json (config)

- -

- Config para ~/.pi/agent/models.json -

+} +``` -

openclaw.json (config)

- -

- Config para ~/.openclaw/openclaw.json -

-

- maxTokens: 65536 es el máximo que soporta el modelo. params.maxTokens: 16000 es lo que se envía por request. 16K es un buen balance para la mayoría de tareas. Si necesitas respuestas más largas, súbelo — pero ten en cuenta que el reasoning también consume de ese presupuesto. -

- -

settings.json (Zed)

- -

- Config para ~/.config/zed/settings.json — incluye inline predictions. -

- - -

modelo: qwen3-embedding

-

embeddings vectoriales

- -

curl

- +# → 4096-dimensional vectors per input +``` + +### python -

python

- +print(len(embeddings[0])) // 4096 +``` -

node.js

- d.embedding); -console.log(embeddings[0].length); // 4096`} /> +console.log(embeddings[0].length); // 4096 +``` + +## modelo: kokoro - -

modelo: kokoro

-

text-to-speech

+text-to-speech -

curl

- +# Ver todas las voces: https://github.com/hexgrad/Kokoro-82M +``` + +### python -

python

- +) +``` + +### node.js -

node.js

- - - -

modelo: whisper

-

speech-to-text

- -

curl

- +curl https://api.nan.builders/v1/audio/translations \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -F "model=whisper" \ + -F "file=@grabacion.mp3" +``` + +### python -

python

- +print(translation.text) # English translation +``` -

node.js

- - -
- -
-

Integración en IDEs

-
-
-

Cursor

-

Settings → OpenAI API → Base URL: https://api.nan.builders/v1, API Key: tu key

-
-
-

Zed

-

Settings → settings.json → ver config completo arriba

-
-
-

Cline / Continue / Aider

-

Configura las vars de entorno:

-
export OPENAI_BASE_URL="https://api.nan.builders/v1"
-export OPENAI_API_KEY="sk-tu-key-aqui"
-
-
-
-
+console.log(result.duration); // 5.2 +``` + +## Integración en IDEs + +- **Cursor**: Settings → OpenAI API → Base URL: `https://api.nan.builders/v1`, API Key: tu key +- **Zed**: Settings → `settings.json` → ver [config completo arriba](#qwen36-zed) +- **Cline / Continue / Aider**: Configura las vars de entorno: + +```bash +export OPENAI_BASE_URL="https://api.nan.builders/v1" +export OPENAI_API_KEY="sk-tu-key-aqui" +``` diff --git a/src/content/docs/getting-started.md b/src/content/docs/getting-started.md new file mode 100644 index 0000000..f08bd19 --- /dev/null +++ b/src/content/docs/getting-started.md @@ -0,0 +1,38 @@ +--- +title: Empezar +description: Configura tu IDE o herramienta favorita para conectar a los modelos de NaN. +order: 1 +--- + +# Conectarse. + +El acceso es vía LiteLLM con una API compatible con OpenAI. Funciona con cualquier herramienta que acepte un `base URL` + `API key`: Cursor, Cline, Continue, Aider, Open Code, Open WebUI o cualquier SDK compatible con OpenAI. + +## Obtener tu API Key + +Debes estar dentro de la comunidad NaN. Si ya estás suscrito, genera tu API Key desde la sección de ajustes del usuario en el apartado "API Keys" de la [plataforma](https://cloud.nan.builders/). La key es personal e intransferible. + +> **Nota** +> El soporte es solo para temas técnicos. + +## Configurar tu herramienta + +| Campo | Valor | +|---|---| +| base URL | `https://api.nan.builders/v1` | +| API Key | `sk-tu-key-aqui` | +| Model | `qwen3.6` | + +Ejemplo de configuración OpenAI-compatible: + +```json +provider: { + openai: { + npm: "@ai-sdk/openai", + name: "NaN", + apiKey: "sk-tu-key-aqui", + baseURL: "https://api.nan.builders/v1", + model: "qwen3.6" + } +} +``` diff --git a/src/content/docs/intro.md b/src/content/docs/intro.md new file mode 100644 index 0000000..c9e807e --- /dev/null +++ b/src/content/docs/intro.md @@ -0,0 +1,26 @@ +--- +title: Introducción +description: Conecta tus herramientas favoritas (OpenCode, Cursor, Cline, etc) a nuestro cluster compartido de inferencia. +order: 0 +--- + +# Bienvenido a NaN. + +Esta doc explica cómo conectar tus herramientas a nuestras GPUs. El cluster corre modelos abiertos con una API compatible con OpenAI. Si algo acepta un `base URL` + `API key`, funciona con NaN. + +> **Para obtener tu API Key** +> Debes estar dentro de la comunidad NaN. Puedes generar tu API Key desde la sección de ajustes del usuario en el apartado "API Keys" de la [plataforma](https://cloud.nan.builders/). La key es personal e intransferible. + +## Rate limits + +| Métrica | Valor | +|---|---| +| Requests por minuto | 100 rpm | +| Paralelo máximo | 5 concurrentes | + +## Qué hacer a continuación + +- [Conectarse](/docs/getting-started): endpoint, auth y configuración paso a paso. +- [Modelos](/docs/models): capacidades y límites de los modelos. +- [Ejemplos](/docs/examples): snippets en Python, Node.js y curl. +- Soporte: reporta problemas por `#support` en Discord. diff --git a/src/content/docs/models.mdx b/src/content/docs/models.mdx new file mode 100644 index 0000000..e4b4d08 --- /dev/null +++ b/src/content/docs/models.mdx @@ -0,0 +1,161 @@ +--- +title: Modelos +description: Especificaciones técnicas, capacidades y parámetros de los modelos del cluster compartido. +order: 3 +--- + +import ModelCard from '../../components/docs/ModelCard.astro'; +import LimitationsCard from '../../components/docs/LimitationsCard.astro'; +import RateLimits from '../../components/docs/RateLimits.astro'; + +# Models del cluster. + +Los modelos de la comunidad. Todos se acceden por la misma API OpenAI-compatible +con el mismo `base URL`. + + + + + + + + + +af_heart — English (female)', + 'ef_dora — Spanish (female)', + 'em_alex — Spanish (male)', + '67 voice packs en total (ver listado completo)', + ]} +/> + + + + 2 min de duración', + body: 'Whisper procesa en CPU a ~1x realtime. Para audios de más de ~2 minutos, el proxy puede devolver un error 524 (timeout) antes de que termine la transcripción. Usa formatos comprimidos como OGG/Opus y divide archivos largos en segmentos de ≤ 2 minutos para evitarlo.', + }, + { + title: 'Formatos recomendados', + body: 'OGG/Opus y MP3 — archivos más pequeños, misma calidad de transcripción. Un audio de 60 min en OGG/Opus a 48 kbps ocupa ~20 MB vs ~550 MB en WAV.', + }, + ]} +/> + + diff --git a/src/layouts/Docs.astro b/src/layouts/Docs.astro index 2ee8c94..d8d6727 100644 --- a/src/layouts/Docs.astro +++ b/src/layouts/Docs.astro @@ -1,5 +1,6 @@ --- import '../styles/global.css'; +import { getCollection } from 'astro:content'; interface Props { title: string; @@ -10,31 +11,29 @@ const { title, description = 'Documentación de NaN — Conecta a nuestros model const siteUrl = 'https://nan.builders'; +const entries = await getCollection('docs'); +entries.sort((a, b) => a.data.order - b.data.order); + interface NavItem { slug: string; label: string; } -const navItems: NavItem[] = [ - { slug: '/docs', label: 'Introducción' }, - { slug: '/docs/getting-started', label: 'Empezar' }, - { slug: '/docs/api', label: 'API' }, - { slug: '/docs/models', label: 'Models' }, - { slug: '/docs/examples', label: 'Ejemplos' }, - { slug: '/docs/agents', label: 'Agents' }, - { slug: '/docs/apps', label: 'Apps' }, -]; - -const currentPageIndex = navItems.findIndex(item => item.slug === Astro.url.pathname); +const navItems: NavItem[] = entries.map((entry) => ({ + slug: entry.id === 'intro' ? '/docs' : `/docs/${entry.id}`, + label: entry.data.title, +})); +const currentPageIndex = navItems.findIndex((item) => item.slug === Astro.url.pathname); const prevPage = currentPageIndex > 0 ? navItems[currentPageIndex - 1] : null; -const nextPage = currentPageIndex < navItems.length - 1 ? navItems[currentPageIndex + 1] : null; +const nextPage = currentPageIndex >= 0 && currentPageIndex < navItems.length - 1 + ? navItems[currentPageIndex + 1] + : null; -// Build breadcrumb from current path const pathParts = Astro.url.pathname.split('/').filter(Boolean); const breadcrumbSegments = pathParts.map((part, i) => { const path = '/' + pathParts.slice(0, i + 1).join('/'); - const item = navItems.find(n => n.slug === path); + const item = navItems.find((n) => n.slug === path); return { label: item ? item.label : part.replace(/-/g, ' '), path: path === Astro.url.pathname ? null : path, @@ -150,7 +149,9 @@ const breadcrumbSegments = pathParts.map((part, i) => {
+ +
@@ -228,18 +229,18 @@ const breadcrumbSegments = pathParts.map((part, i) => { // Build TOC from h2/h3 headings const tocContainer = document.getElementById('toc'); const headings = document.querySelectorAll('.docs-content h2, .docs-content h3'); - + if (headings.length > 0 && tocContainer) { headings.forEach((heading) => { const id = heading.id || heading.textContent?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+$/, ''); if (id) heading.id = id; - + const link = document.createElement('a'); link.href = `#${id}`; link.className = `toc-link ${heading.tagName === 'H3' ? 'h2' : ''}`; link.textContent = heading.textContent || ''; link.dataset.target = id; - + link.addEventListener('click', (e) => { e.preventDefault(); const target = document.getElementById(id); @@ -248,7 +249,7 @@ const breadcrumbSegments = pathParts.map((part, i) => { history.replaceState(null, '', `#${id}`); } }); - + tocContainer.appendChild(link); }); @@ -273,26 +274,85 @@ const breadcrumbSegments = pathParts.map((part, i) => { }); } - // Copy-to-clipboard via event delegation - document.addEventListener('click', (e) => { - const btn = (e.target as HTMLElement).closest('.copy-btn'); - if (!btn) return; - const block = btn.closest('.code-block') as HTMLElement; - const codeText = block?.dataset?.code; - if (!codeText) return; - navigator.clipboard.writeText(codeText).then(() => { - const label = btn.querySelector('.copy-label') as HTMLElement | null; - if (label) { - label.textContent = 'Copiado!'; - setTimeout(() => { label.textContent = 'Copiar'; }, 1500); - } - }).catch(() => { - const label = btn.querySelector('.copy-label') as HTMLElement | null; - if (label) { - label.textContent = 'Error'; - setTimeout(() => { label.textContent = 'Copiar'; }, 2000); + // Progressive enhancement for Markdown-generated
 blocks:
+      // wrap each in a toolbar with language label + copy button.
+      const prettifyLanguage = (raw: string | null | undefined) => {
+        const value = (raw || 'text').replace(/^language-/, '').toLowerCase();
+        const labels: Record = {
+          js: 'JavaScript',
+          ts: 'TypeScript',
+          json: 'JSON',
+          bash: 'Bash',
+          shell: 'Shell',
+          sh: 'Shell',
+          curl: 'cURL',
+          python: 'Python',
+          py: 'Python',
+          node: 'Node.js',
+          nodejs: 'Node.js',
+          'node.js': 'Node.js',
+          zed: 'Zed',
+          text: 'Text',
+        };
+        return {
+          key: value,
+          label: labels[value] || value,
+        };
+      };
+
+      const copyToast = document.getElementById('copy-toast');
+      const showCopyToast = (text: string) => {
+        if (!copyToast) return;
+        copyToast.textContent = text;
+        copyToast.classList.add('show');
+        window.setTimeout(() => copyToast.classList.remove('show'), 1500);
+      };
+
+      document.querySelectorAll('.docs-content pre').forEach((pre) => {
+        if (pre.closest('.docs-code-block') || pre.closest('.code-block')) return;
+
+        const code = pre.querySelector('code');
+        const classLanguage = code?.className.match(/language-([\w.-]+)/)?.[1];
+        const attrLanguage = pre.getAttribute('data-language');
+        const lang = prettifyLanguage(attrLanguage || classLanguage);
+
+        const wrapper = document.createElement('div');
+        wrapper.className = 'docs-code-block';
+
+        const toolbar = document.createElement('div');
+        toolbar.className = 'docs-code-toolbar';
+
+        const label = document.createElement('span');
+        label.className = 'docs-code-label';
+        label.textContent = lang.label;
+
+        const button = document.createElement('button');
+        button.type = 'button';
+        button.className = 'copy-btn';
+        button.setAttribute('aria-label', `Copiar ${lang.label}`);
+        button.innerHTML = 'Copiar';
+        button.addEventListener('click', async () => {
+          const text = pre.textContent || '';
+          try {
+            await navigator.clipboard.writeText(text);
+            const copyLabel = button.querySelector('.copy-label');
+            if (copyLabel) copyLabel.textContent = 'Copiado!';
+            showCopyToast('¡Copiado al portapapeles!');
+            window.setTimeout(() => {
+              const resetLabel = button.querySelector('.copy-label');
+              if (resetLabel) resetLabel.textContent = 'Copiar';
+            }, 1500);
+          } catch (err) {
+            console.error('copy failed', err);
+            showCopyToast('Error al copiar');
           }
         });
+
+        toolbar.appendChild(label);
+        toolbar.appendChild(button);
+        pre.parentNode?.insertBefore(wrapper, pre);
+        wrapper.appendChild(toolbar);
+        wrapper.appendChild(pre);
       });
     
   
diff --git a/src/lib/contentHash.ts b/src/lib/contentHash.ts
new file mode 100644
index 0000000..1ea7401
--- /dev/null
+++ b/src/lib/contentHash.ts
@@ -0,0 +1,7 @@
+export async function sha256Hex(text: string): Promise {
+  const buf = new TextEncoder().encode(text);
+  const hash = await crypto.subtle.digest('SHA-256', buf);
+  return Array.from(new Uint8Array(hash))
+    .map((b) => b.toString(16).padStart(2, '0'))
+    .join('');
+}
diff --git a/src/lib/htmlToText.ts b/src/lib/htmlToText.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2b9f2adf8f92f77f6c7e4a82e83c8e3d4e5e5ab6
GIT binary patch
literal 4464
zcmb7H-EP}96z+AN;?xal$+4`aEe0%g9JFbQ2Fu!_4KQGq8`BnT38hF)q!(wlfnMxp
z*Xt7u+kj!fUSM~7ls?JMp)CKza+cYEEsBTd=R3dbE2#~fygENUKRtPO0B?C;WnojB
zESAGMp<
ziZ=hqH~Ekjf7yk}SZEpj_zSLQPj+?&BiT2xk`N*YAwQ8OHZj)@8j<7R77rGUjGIv=
zL8Ea1jf|&)^||BUzc+T;54cfP7w-idewou
zhlendAs@t&hj6rrl==1nB(D2pXSM5l?_=uo)345%NrfRuHO7tTq{E38YMGuEC+iN>8Q0@T$XQ6dSHn
zOrxAzUkQNk6oy0y-D{WX&duz{f)tbj!oJz-+=dPcaL09Tk;4
zvx!CVLm!GE!Vnd)nX;saJrggmmhjMr6Ntj|%IZXog7rSI;9AejYncS4#jvo4T(yS|Ni
z+{|g|RJXu5w=NFH$Zr`ujdY|V$I5ZRgZ+YfUR}C(j$c1Nxp;Q=`uMAh-3gwa-I;To
zQRBl(gum#Mmz;$Nt1c@C(SB)^Mf*7{HmZz`BWve9*qy9oj$>RhNOo=2TaRqN89glL
z{csEK|Hu4MIrB%`nEyZSkIT6~-pc(3_PH0%Vr@!YAmUP4BHoncmBC*Qe&9PJF;&9N
zxl{vLUe)JRg$j8x7Lekip9%h5v8Hd*PqlyZf78CJKzKU#1j
zvRswtT-#Pdqmm#v!3m8YhHhD8hl^IVRzE|t32Uvk5!JQY0h9w9hJ_XDe8Ww?8(g<#
zO5lfL&ED&6!f%G65WDH3bJi0ahegABp5{2xF@rNxrY>~@oHL~~w%3ut0?R_8gn`O{
zCp{kKWr1eC>$z*9Lfo2_Cc2H4rW`p_j-0s_a$9%?zRCUrIm`qEM(`J*O;)5wc`V*^u)wEiA4QOU6CwKF^Q@||KYjuw@w~#>qmDh@P9j|5^i30wy-9>nX;x4H
zu4#rDa+Bv##yx7{8IAxHC^#@-gMNAb^%-?u=%GqD)8M0LOMObxGCt(vLe1;fl!d0p
z`P{@;2CCxB$_(0uz;yNkX~)1VwIYy{NA=m9Vx0ytN}Pqrw_F>AGa|K3&rZN=(_e{r
zI9lnO_I3kD`Av!LBevHqdvN>J2ooi9q}$2IA{4xlEhWk#QE^RMzoo2p&)W|C8v>hb
zIW4Z`CtFERR)@TEPf(d+f{HReC?-H8sM0!*{eogXW3B3G+(rR9ahe-uSsg*QoliTn
zjXsJl@}lGLWEyXgVz;(P
z*r?sLa{d%5r8dyU3h!cw!Xfb*w2KRTcYZIL1>JI48`EeJV#`T0&n?Rcs!O-ccK!t|
C3QSG_

literal 0
HcmV?d00001

diff --git a/src/pages/api/docs/[slug].md.ts b/src/pages/api/docs/[slug].md.ts
new file mode 100644
index 0000000..46d1aa3
--- /dev/null
+++ b/src/pages/api/docs/[slug].md.ts
@@ -0,0 +1,63 @@
+import type { APIRoute } from 'astro';
+import { getEntry } from 'astro:content';
+import { sha256Hex } from '../../../lib/contentHash';
+import { htmlToText } from '../../../lib/htmlToText';
+
+export const prerender = false;
+
+const SAFE_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
+
+function extractArticle(html: string): string {
+  const start = html.indexOf('');
+  const end = html.indexOf('');
+  if (start === -1 || end === -1 || end <= start) return '';
+  return html.slice(start + ''.length, end);
+}
+
+async function renderEntryText(slug: string, origin: string, fetcher: typeof fetch): Promise {
+  const res = await fetcher(`${origin}/docs/${slug}`);
+  if (!res.ok) throw new Error(`Failed to render /docs/${slug}: ${res.status}`);
+  const html = await res.text();
+  const article = extractArticle(html);
+  return htmlToText(article);
+}
+
+export const GET: APIRoute = async ({ params, request }) => {
+  const slug = params.slug;
+
+  if (!slug || !SAFE_SLUG.test(slug)) {
+    return new Response('Invalid slug', { status: 400 });
+  }
+
+  const entry = await getEntry('docs', slug);
+  if (!entry) {
+    return new Response('Not found', { status: 404 });
+  }
+
+  const url = new URL(request.url);
+  const body = await renderEntryText(slug, url.origin, fetch);
+  const contentHash = `sha256:${await sha256Hex(body)}`;
+
+  const frontmatter = [
+    '---',
+    `title: ${JSON.stringify(entry.data.title)}`,
+    `description: ${JSON.stringify(entry.data.description)}`,
+    `order: ${entry.data.order}`,
+    `slug: ${entry.id}`,
+    '---',
+    '',
+  ].join('\n');
+
+  const responseBody = `${frontmatter}\n${body}`;
+  const etag = `sha256:${await sha256Hex(responseBody)}`;
+
+  return new Response(responseBody, {
+    status: 200,
+    headers: {
+      'Content-Type': 'text/markdown; charset=utf-8',
+      'Cache-Control': 'public, max-age=60, s-maxage=300',
+      'ETag': `"${etag}"`,
+      'X-Content-Hash': contentHash,
+    },
+  });
+};
diff --git a/src/pages/api/docs/manifest.json.ts b/src/pages/api/docs/manifest.json.ts
new file mode 100644
index 0000000..b14bbea
--- /dev/null
+++ b/src/pages/api/docs/manifest.json.ts
@@ -0,0 +1,53 @@
+import type { APIRoute } from 'astro';
+import { getCollection } from 'astro:content';
+import { sha256Hex } from '../../../lib/contentHash';
+import { htmlToText } from '../../../lib/htmlToText';
+
+export const prerender = false;
+
+function extractArticle(html: string): string {
+  const start = html.indexOf('');
+  const end = html.indexOf('');
+  if (start === -1 || end === -1 || end <= start) return '';
+  return html.slice(start + ''.length, end);
+}
+
+export const GET: APIRoute = async ({ request }) => {
+  const entries = await getCollection('docs');
+  entries.sort((a, b) => a.data.order - b.data.order);
+
+  const origin = new URL(request.url).origin;
+
+  const manifestEntries = await Promise.all(
+    entries.map(async (entry) => {
+      const res = await fetch(`${origin}/docs/${entry.id}`);
+      const html = res.ok ? await res.text() : '';
+      const text = htmlToText(extractArticle(html));
+      return {
+        slug: entry.id,
+        title: entry.data.title,
+        description: entry.data.description,
+        order: entry.data.order,
+        contentHash: `sha256:${await sha256Hex(text)}`,
+        contentUrl: `/api/docs/${entry.id}.md`,
+      };
+    }),
+  );
+
+  const version = `sha256:${await sha256Hex(JSON.stringify(manifestEntries))}`;
+  const payload = {
+    version,
+    entries: manifestEntries,
+  };
+  const body = JSON.stringify(payload, null, 2);
+  const etag = `sha256:${await sha256Hex(body)}`;
+
+  return new Response(body, {
+    status: 200,
+    headers: {
+      'Content-Type': 'application/json; charset=utf-8',
+      'Cache-Control': 'public, max-age=60, s-maxage=300',
+      'ETag': `"${etag}"`,
+    },
+  });
+};
diff --git a/src/pages/docs/[...slug].astro b/src/pages/docs/[...slug].astro
new file mode 100644
index 0000000..979eac7
--- /dev/null
+++ b/src/pages/docs/[...slug].astro
@@ -0,0 +1,19 @@
+---
+import { getEntry, render } from 'astro:content';
+import Docs from '../../layouts/Docs.astro';
+
+const slugParam = Astro.params.slug;
+const slug = slugParam && slugParam.length > 0 ? slugParam : 'intro';
+
+const entry = await getEntry('docs', slug);
+
+if (!entry) {
+  return new Response('Not found', { status: 404 });
+}
+
+const { Content } = await render(entry);
+---
+
+
+  
+
diff --git a/src/pages/docs/agents.astro b/src/pages/docs/agents.astro
deleted file mode 100644
index 6f65b16..0000000
--- a/src/pages/docs/agents.astro
+++ /dev/null
@@ -1,253 +0,0 @@
----
-import Docs from '../../layouts/Docs.astro';
----
-
-
-  
-

- // agents -

-
- -

Agents.

- -

NaN Cloud te deja desplegar agentes de IA en tu propia microVM: - una máquina virtual ligera con QEMU + KVM, su propio kernel, su propio - filesystem y acceso root completo. Aislada del host y del resto de miembros. El - primer tipo de agente disponible es Hermes.

- - - - -

Arquitectura

- -

Cada agente corre dentro de su propia microVM con QEMU. En lugar de - compartir el kernel del host (como un container normal), arranca con su - propio kernel Linux. La VM monta un disco ext4 de 20 GiB sobre un volumen - en modo block, persistente. Todo lo que haces dentro - —apt install, pip install, edits en - /etc, ficheros que subas, sesiones de bash— vive en ese disco - y sobrevive a reinicios.

- -

El shutdown es graceful: cuando reinicies o borres el agente, el - sistema fuerza un sync y espera a que el journal de ext4 - termine de vaciar antes de matar la VM. Sin corrupciones.

- - - - -

Hermes

- -

Hermes es un agente de IA conversacional que se conecta a Telegram. - Puedes hablar con él, pedirle que gestione notas, ejecute comandos - en su entorno, genere sitios web y mucho más.

- -

1. Crear un bot de Telegram

- -

Necesitas un bot de Telegram. Abre Telegram, busca - @BotFather - y sigue las instrucciones para crear un bot nuevo. Copia el token que te da.

- -

2. Crear el agente

- -

Ve a cloud.nan.builders/agents/new - y rellena: nombre, tipo (Hermes), el token de Telegram, modelo y opcionalmente - un soul (system prompt) que defina la personalidad de tu agente.

- -
- Formulario de creación de agente -
- -

3. Esperar a que esté Running

- -

Tras crear el agente espera ~30 segundos a que el microVM arranque, formatee - el disco la primera vez (mkfs.ext4) y siembre el sistema de - ficheros. El estado pasa a Running y Hermes a Ready.

- -

4. Hablar con tu agente

- -

Busca tu bot en Telegram y envíale un mensaje. Hermes responderá usando - el modelo que hayas configurado.

- -
-
- Conversación con Hermes en Telegram -
-
- -
-

Tu agente está listo.

-

- Con estos 4 pasos ya tienes a Hermes funcionando. Lo que viene a continuación - son funcionalidades adicionales del panel del agente: terminal web, subida de - ficheros, observabilidad, exposición HTTP, Hermes UI y gestión de variables - de entorno. -

-
- - - - -

Console — terminal web

- -

La pestaña Console abre un terminal - interactivo (bash --login) dentro de tu microVM, sin que tengas - que configurar SSH. El stream va sobre WebSocket con xterm.js: resize - automático cuando ajustas el panel, status pill arriba a la derecha y botón - de reconexión si la sesión se cae.

- -

Casos de uso típicos:

- -
    -
  • Instalar paquetes: apt update && apt install -y nginx
  • -
  • Inspeccionar logs internos del agente
  • -
  • Mover ficheros que hayas subido a su ubicación final
  • -
  • Tirar de htop, df -h, journalctl, etc.
  • -
- -
-

- Límites operativos: 1 sesión simultánea por agente · idle timeout 10 min · - duración máxima 30 min por sesión. -

-
- - - - -

Files — subida de ficheros

- -

La pestaña Files permite subir ficheros - al microVM con drag-and-drop o picker. Multi-fichero, cola secuencial, - progress bar en vivo con MiB/s. Los archivos aterrizan en - /persist/uploads/ y desde ahí los puedes mover con la Console.

- -
    -
  • Tamaño máximo: 200 MiB por fichero.
  • -
  • Transporte: WebSocket con chunks de 256 KiB y backpressure end-to-end.
  • -
  • Filename sanitizado server-side (sin path traversal).
  • -
  • Listado en vivo de lo ya subido (refresca cada 5 s).
  • -
- - - - -

Observability

- -

La pestaña Observability agrupa tres - sub-pestañas:

- -
    -
  • Logs — stream en vivo de stdout/stderr - del agente vía WebSocket. Buffer de las últimas 500 líneas en el cliente.
  • -
  • Events — eventos de Kubernetes del Pod - (BackOff, Scheduled, Pulled, Killing...) con tipo, razón, mensaje, edad y - contador. Auto-refresh cada 15 s.
  • -
  • Metrics — uso real de CPU, RAM y disco - contra los límites configurados. CPU/RAM vía Prometheus - (kubelet-cadvisor), disco vía df dentro del microVM (el - filesystem es block-mode, kubelet no lo ve). Refresca cada 10 s.
  • -
- - - - -

Web — exposición pública

- -

La pestaña Web tiene dos sub-pestañas - para sacar servicios HTTP del agente:

- -

HTTP

- -

Cualquier servicio que tu agente sirva por HTTP (nginx, una API, un - static-site) lo puedes exponer públicamente. Por ejemplo, pídele a Hermes - que instale nginx con un HTML personalizado:

- -
-
- Pidiendo a Hermes que instale nginx con un HTML personalizado -
-
- -

En la pestaña Web → HTTP pulsa - Enable HTTP. Por defecto se expone el - puerto 80; si tu servicio escucha en otro puerto, indícalo en - Container Port. La plataforma genera una URL pública en - *.apps.nan.builders.

- -
- Sitio web generado por Hermes visible desde la URL pública -
- -

Hermes UI

- -

Hermes incluye una UI web ligera - (nesquena/hermes-webui) - que se ejecuta siempre dentro del agente. Desde Web → Hermes UI - puedes habilitar acceso externo: la plataforma genera una URL del estilo - webui-<agent>-<user>.apps.nan.builders protegida por - una contraseña per-agent que aparece en el panel.

- - - - -

Variables de entorno

- -

La pestaña Env permite añadir, editar - y borrar variables de entorno del agente sin tocar el Deployment. Útil para - inyectar API keys de terceros, configurar comportamiento de Hermes, etc.

- -

Dos variables son protegidas (sólo - edit, no delete): OPENAI_API_KEY (tu key del cluster, gestionada - por la plataforma) y TELEGRAM_BOT_TOKEN. El resto son - creación / edición / borrado libre.

- - - - -

Recursos y límites

- -

Cada microVM se aprovisiona con:

- -
- - - - - - - - - - - - - - - - - - - - - - - - - -
RecursoRequestLimit
CPU200m1 vCPU
RAM512 Mi2 GiB
Disco—20 GiB (PVC block-mode)
-
- -

CPU y RAM son los límites máximos del - microVM; el uso real suele estar muy por debajo. El disco es persistente — - todo lo que instales o modifiques (paquetes, archivos, configuraciones) - se conserva entre reinicios. Si el disco se llena (90%+), libéralo desde la - Console (du -sh /persist/*).

- -
-

- Actualmente cada miembro puede desplegar 1 agente microVM. - Este límite se ampliará en futuras versiones. -

-
-
diff --git a/src/pages/docs/api.astro b/src/pages/docs/api.astro deleted file mode 100644 index 88552b3..0000000 --- a/src/pages/docs/api.astro +++ /dev/null @@ -1,1062 +0,0 @@ ---- -import Docs from '../../layouts/Docs.astro'; -import CodeBlock from '../../components/docs/CodeBlock.astro'; -import RateLimits from '../../components/docs/RateLimits.astro'; ---- - - -
-

- // api -

-
- -

Referencia de la API.

- -

Nuestra API es compatible con OpenAI: cualquier cliente o SDK que acepte un - base URL + API key funciona sin cambios. La base - URL es https://api.nan.builders/v1 y la autenticación es vía - Bearer token. Para obtener tu key, consulta - Empezar.

- -
-
- -
-

Servicio enterprise de Helmcode

-

- Si usas el servicio enterprise de Helmcode recuerda que la URL de la API es api.helmcode.com. El resto de los endpoints es idéntico. -

-
-
-
- - -

Endpoints

- -

- Listado de los endpoints disponibles. Cada uno enlaza a su sección con - request, response y un ejemplo en curl. -

- - - - -

Autenticación

- -

- Todas las peticiones requieren el header Authorization: Bearer <api-key>. - La key es personal e intransferible — consulta - Empezar - para obtener la tuya. -

- - - - -

GET /v1/models

- -

- Devuelve la lista de modelos disponibles para tu key. Modelos publicados: - deepseek-v4-flash, qwen3.6, gemma4, - qwen3-embedding, kokoro, whisper. -

- -

Request

-
-

- Sin body. Solo el header de autenticación. -

-
- -

Response

- - -

Ejemplo

- - - -

POST /v1/chat/completions

- -

- El endpoint principal de chat. Compatible con OpenAI Chat Completions. - Modelos compatibles: deepseek-v4-flash, qwen3.6 y - gemma4. -

- -
-

- capacidades por modelo -

-
-
-
deepseek-v4-flash
-
- Chat, streaming, tool calling, reasoning, contexto de 1M tokens. - Cuota mensual de 100M tokens por miembro. -
-
-
-
qwen3.6
-
- Chat, streaming, tool calling, vision (image input), reasoning - (opt-out, devuelve reasoning_content en el message). -
-
-
-
gemma4
-
- Chat, streaming, vision (image input), reasoning (opt-in). -
-
-
-
- -

Request

-
-
-
-
-
model
- string · required -
-
qwen3.6 o gemma4.
-
-
-
-
messages
- array · required -
-
- Lista de mensajes {`{ role, content }`}. content - puede ser string o un array de partes - {`[{type:"text",text}, {type:"image_url",image_url:{url}}]`} - para input multimodal. -
-
-
-
-
max_tokens
- integer · optional -
-
Tope de tokens generados.
-
-
-
-
stream
- boolean · optional -
-
- Default false. Si true, la respuesta llega como SSE. -
-
-
-
-
tools
- array · optional -
-
- Function calling estándar OpenAI: - {`{type:"function",function:{name,description,parameters}}`}. - Validado solo con qwen3.6. -
-
-
-
-
tool_choice
- string | object · optional -
-
- Controla qué tool puede invocar el modelo. Estándar OpenAI. -
-
-
-
-
temperature
- number · optional -
-
Default 0.6.
-
-
-
-
top_p
- number · optional -
-
Default 0.95.
-
-
-
- -

Response

-

- Respuesta sin streaming. finish_reason puede ser stop, - length o tool_calls. -

- -

- El campo reasoning_content se incluye solo cuando se usa - qwen3.6. Es opcional ignorarlo. -

- -

Ejemplo

- - -

Streaming

-

- Con stream: true, la respuesta se entrega como Server-Sent Events. - Cada chunk es data: {`{...}`}\n\n con el delta en - choices[0].delta.content. El stream termina con - data: [DONE]. -

- - -

Tool calling

-

- qwen3.6 soporta function calling estándar OpenAI. Cuando el - modelo decide invocar una tool, la respuesta incluye - choices[0].message.tool_calls con - {`{id, type:"function", function:{name, arguments}}`} y - finish_reason: "tool_calls". -

- - -

Vision

-

- Tanto qwen3.6 como gemma4 aceptan input multimodal. - El campo content del mensaje pasa de string a un array de partes - de tipo text y/o image_url. -

- - -

Structured outputs

- -

Los modelos de chat aceptan el campo response_format estándar - de OpenAI para forzar respuestas JSON válidas. Soportamos los dos modos:

- -
-
-
-
json_object
-
- Garantiza que la respuesta sea JSON sintácticamente válido. No - impone estructura. -
-
-
-
json_schema
-
- Restringe la salida a un JSON Schema concreto. Con strict: true - el modelo no puede emitir campos fuera del schema. -
-
-
-
- -

Funciona en qwen3.6 y gemma4.

- -

- json_object -

- - - -

- json_schema (strict) -

- - - -

- Con el SDK de openai en Python: -

- - - -

Reasoning

- -

Ambos modelos soportan reasoning. El toggle se controla con el campo - chat_template_kwargs.enable_thinking en el body del request. - Cuando está activo, la respuesta incluye reasoning_content en - message con la cadena de razonamiento del modelo.

- -
-
-
-
qwen3.6
-
activo por defecto
-
-
-
gemma4
-
desactivado por defecto
-
-
-
- - - -

- Para desactivarlo en qwen3.6 pasa {`{ "enable_thinking": false }`}. -

- -

- En SDKs como openai de Python o Node, este campo va dentro de - extra_body: -

- - - - -

POST /v1/completions

- -

- Endpoint legacy de OpenAI para text completion. Modelo compatible: - qwen3.6. -

- -

Request

-
-
-
-
-
model
- string · required -
-
qwen3.6.
-
-
-
-
prompt
- string · required -
-
El prompt a completar.
-
-
-
-
max_tokens
- integer · optional -
-
Tope de tokens generados.
-
-
-
-
temperature
- number · optional -
-
Default 0.6.
-
-
-
-
top_p
- number · optional -
-
Default 0.95.
-
-
-
-
stream
- boolean · optional -
-
Default false.
-
-
-
- -

Response

- - -

Ejemplo

- - -

Notas

-
-

- Endpoint legacy de OpenAI. Para conversaciones, usa - /v1/chat/completions. -

-
- - -

POST /v1/embeddings

- -

- Genera embeddings vectoriales. Modelo compatible: - qwen3-embedding. Vectores de 4096 dimensiones. -

- -

Request

-
-
-
-
-
model
- string · required -
-
qwen3-embedding.
-
-
-
-
input
- string | array · required -
-
- Texto único o array de strings a embeddear. -
-
-
-
-
encoding_format
- string · optional -
-
- "float" (default) o "base64". -
-
-
-
- -

Response

- - -

Ejemplo

- - - -

POST /v1/audio/speech

- -

- Sintetiza audio a partir de texto (text-to-speech). Modelo compatible: - kokoro. -

- -

Request

-
-
-
-
-
model
- string · required -
-
kokoro.
-
-
-
-
input
- string · required -
-
- Texto a sintetizar. -
-
-
-
-
voice
- string · required -
-
- Voz a usar. Algunas opciones: af_heart (English female), - ef_dora (Spanish female), em_alex (Spanish male). - Ver listado completo. -
-
-
-
-
response_format
- string · optional -
-
- Formato del audio devuelto. Validados: mp3 (default), - wav, flac, aac, pcm, - opus. -
-
-
-
-
speed
- number · optional -
-
Default 1.0.
-
-
-
- -

Response

-

- Archivo binario de audio en el formato pedido (sin envoltorio JSON). -

- -

Ejemplo

- - - -

POST /v1/audio/transcriptions

- -

- Transcribe audio a texto (speech-to-text). Modelo compatible: - whisper. La petición es multipart/form-data. -

- -

Request

-
-
-
-
-
file
- file · required -
-
Archivo de audio a transcribir.
-
-
-
-
model
- string · required -
-
whisper.
-
-
-
-
language
- string · optional -
-
- Código ISO-639-1 (ej. es, en). Si no se pasa, - se detecta automáticamente. -
-
-
-
-
response_format
- string · optional -
-
- Validados: json (default) y verbose_json. Otros - valores funcionan pero devuelven el contenido envuelto en JSON; - recomendamos solo estos dos. -
-
-
-
-
timestamp_granularities[]
- string · optional -
-
- Solo con verbose_json. Valores: word - (timestamps por palabra) o segment (default). -
-
-
-
-
temperature
- number · optional -
-
Sampling temperature.
-
-
-
- -

Response

-

- Ejemplo con response_format=verbose_json: -

- -

- Si pasas timestamp_granularities[]=word, el campo words - se llena con {`[{word, start, end, probability}]`}. -

- -

Ejemplo

- - -

Limitaciones

-
-
-
-
Tamaño máximo por request — 25 MB
-
- Límite de tamaño del archivo de audio. -
-
-
-
Audios > 2 min pueden devolver timeout 524
-
- Recomendamos dividir en segmentos de ≤ 2 min. -
-
-
-
Formatos recomendados
-
- OGG/Opus y MP3 — mejor compresión, misma calidad - de transcripción. -
-
-
-
- - -

POST /v1/responses

- -

- Endpoint Responses estilo OpenAI. Modelos compatibles: - qwen3.6 y gemma4. -

- -

Request

-
-
-
-
-
model
- string · required -
-
qwen3.6 o gemma4.
-
-
-
-
input
- string | array · required -
-
- Texto único o array de mensajes en formato OpenAI Responses. -
-
-
-
-
max_output_tokens
- integer · optional -
-
- Default 65536 en qwen3.6. -
-
-
-
-
temperature
- number · optional -
-
Default 0.6.
-
-
-
-
top_p
- number · optional -
-
Default 0.95.
-
-
-
-
instructions
- string · optional -
-
Instrucciones de sistema.
-
-
-
- -

Response

-

- El array output puede contener bloques de tipo - reasoning (solo qwen3.6) y message. -

- - -

Ejemplo

- - -

Notas

-
-

- El streaming en este endpoint actualmente entrega un único evento - response.completed al final, no chunks incrementales. Para - streaming token-a-token usa - /v1/chat/completions - con stream: true. -

-
- - -

Errores

- -

- Los errores siguen el formato estándar de OpenAI: HTTP status no-2xx con un - body JSON describiendo el problema. -

- - - -
-

- códigos comunes -

-
-
-
401
-
- Header Authorization inválido o ausente. -
-
-
-
404
-
- Modelo no existe (campo model). -
-
-
-
429
-
- Rate limit excedido — rpm_limit o - max_parallel_requests. -
-
-
-
500
-
- Error interno (incluye errores upstream del modelo). -
-
-
-
524
-
- Timeout (típico con audios grandes en - /v1/audio/transcriptions). -
-
-
-
- - -

Rate limits

- -

- Aplican a todas las peticiones por API key. -

- - -
diff --git a/src/pages/docs/apps.astro b/src/pages/docs/apps.astro deleted file mode 100644 index a68bf30..0000000 --- a/src/pages/docs/apps.astro +++ /dev/null @@ -1,151 +0,0 @@ ---- -import Docs from '../../layouts/Docs.astro'; ---- - - -
-

- // apps -

-
- -

Apps.

- -

NaN Cloud te permite desplegar tus propias apps desde un repositorio - de GitHub: construimos tu imagen, la publicamos en un entorno aislado tuyo y la - servimos detrás de un dominio público con HTTPS. Todo en un clic.

- -
-

Antes de empezar

-

- Las Apps viven dentro de un Space: tu propio - entorno con su cuota de recursos. Si tienes la suscripción de inferencia activa, - recibes un Space Basic gratis incluido en tu - membresía. Si no, puedes comprar uno desde - cloud.nan.builders/spaces. -

-
- - - - -

1. Crear un Space

- -

Entra en cloud.nan.builders/spaces. - Si eres miembro de inferencia verás un panel que te ofrece reclamar tu Space Basic gratis: - elige un slug (entre 1 y 20 caracteres, minúsculas, sin espacios) y pulsa - Claim free Basic. El slug se usará para - construir los dominios públicos de tus apps, así que escógelo con cariño.

- -
- Reclamar un Space Basic gratuito -
- -

El Space se activa al instante.

- - - - -

2. Crear una App dentro del Space

- -

Abre tu Space recién creado. Verás el resumen de recursos consumidos, el - botón Change plan por si quieres subir - de tier en algún momento, y la sección Apps in - this Space. Pulsa New App - para arrancar el formulario.

- -
- Crear una nueva App dentro del Space -
- - - - -

3. Conectar GitHub y configurar la build

- -

Conecta tu cuenta de GitHub autorizando la NaN Cloud GitHub App al repositorio - que vas a desplegar (la primera vez te lleva al flujo oficial de instalación - en github.com). Una vez conectado, selecciona el repo de la lista, elige - la rama y dale un nombre a tu App.

- -
-

Requisito imprescindible: Dockerfile

-

- Tu repositorio debe contener un Dockerfile - en la raíz (o en el path que configures). Sin Dockerfile no podemos construir tu - imagen y la app no se desplegará. Así tienes control total sobre el runtime, - las dependencias y los procesos que arrancan dentro de tu app. -

-
- -

Si tu app es un servicio HTTP (página web, API, panel admin, etc.), marca - Expose over HTTP e indica el - puerto en el que tu app escucha - internamente. Por ejemplo, si arrancas con node server.js - escuchando en :8080, pon 8080 aquí. Nosotros nos - encargamos de publicarla en un dominio público con HTTPS.

- -

Si tu app es un proceso que no necesita ser accesible desde fuera - (un worker, un cron, un consumer de cola...), desmarca Expose over HTTP: - la app arrancará en modo worker, sin URL pública.

- -
- Formulario de creación de App: GitHub + Dockerfile + puerto -
- -

El bloque Environment variables (opcional) te - deja añadir variables tanto de tiempo de ejecución como de build. Y en - Advanced options puedes ajustar réplicas, - CPU/memoria y añadir almacenamiento persistente si tu app necesita guardar - estado.

- -

Pulsa Deploy. En la pantalla de detalle de - la App verás la build en directo. Tras el build, si todo ha ido bien, verás - que el estado pasa a Running.

- - - - -

4. Abrir tu App

- -

Cuando el estado sea Running, pulsa el botón - Open arriba a la derecha. Te abre la URL - pública de tu app en una pestaña nueva.

- -
- App en estado Running con botón Open -
- -

Desde la misma pantalla tienes acceso a los logs de tu app en directo, - sus eventos, métricas (CPU, memoria, disco), gestión de variables de - entorno y un panel de ajustes para mutar rama, Dockerfile, puerto y - recursos en caliente.

- - - - -

5. Tu app, en producción

- -

Y eso es todo. Tu repositorio de GitHub está sirviendo tráfico real desde - un dominio público con HTTPS, sobre infra nuestra. Cada git push - a la rama configurada (con auto-deploy activado) dispara una nueva build - automáticamente.

- -
- Ejemplo de App desplegada y servida -
- -
-

Tu App está viva.

-

- Con estos 5 pasos ya tienes tu app desplegada. Si necesitas escalar - (más recursos, más réplicas, almacenamiento persistente, más Spaces - para separar entornos dev/staging/prod), puedes hacerlo en cualquier - momento desde el dashboard. Apps y Spaces están en - Beta — si encuentras algún - problema, repórtalo en #support en Discord. -

-
- -
-
diff --git a/src/pages/docs/getting-started.astro b/src/pages/docs/getting-started.astro deleted file mode 100644 index d6490a2..0000000 --- a/src/pages/docs/getting-started.astro +++ /dev/null @@ -1,78 +0,0 @@ ---- -import Docs from '../../layouts/Docs.astro'; -import CodeBlock from '../../components/docs/CodeBlock.astro'; ---- - - -
-

- // getting started -

-
- -

Conectarse.

- -

El acceso es vía LiteLLM con una API compatible con OpenAI. Funciona con - cualquier herramienta que acepte un base URL - + API key: Cursor, Cline, Continue, Aider, Open Code, - Open WebUI o cualquier SDK compatible con OpenAI.

- - -

Obtener tu API Key

- -
-

- Debes estar dentro de la comunidad NaN. Si ya estás suscrito, - genera tu API Key desde la sección de ajustes del usuario en el apartado "API Keys" - de la plataforma. - La key es personal e intransferible. -

-
- - El soporte es solo para temas técnicos -
-
- - -

Configurar tu herramienta

- -

- Usa estos valores en tu IDE o herramienta: -

- -
-
-
base URL
-
- https://api.nan.builders/v1 -
-
-
-
API Key
-
- sk-tu-key-aqui -
-
-
-
Model
-
- qwen3.6 -
-
-
- -
-

- ejemplo: config en OpenAI-compatible -

- -
-
diff --git a/src/pages/docs/index.astro b/src/pages/docs/index.astro deleted file mode 100644 index ae5beff..0000000 --- a/src/pages/docs/index.astro +++ /dev/null @@ -1,73 +0,0 @@ ---- -import Docs from '../../layouts/Docs.astro'; -import RateLimits from '../../components/docs/RateLimits.astro'; ---- - - -
-

- // docs -

-
- -

Bienvenido a NaN.

- -

Esta doc explica cómo conectar tus herramientas a nuestras GPUs. El cluster - corre modelos abiertos con una API compatible con OpenAI. Si algo - acepta un base URL + API key, - funciona con NaN.

- -
-
- -
-

Para obtener tu API Key

-

- Debes estar dentro de la comunidad NaN. Puedes generar tu API Key - desde la sección de ajustes del usuario en el apartado "API Keys" - de la plataforma. - La key es personal e intransferible. -

-
-
-
- - -

- rates -

- - -

- qué hacer a continuación -

- - - -
-
diff --git a/src/pages/docs/models.astro b/src/pages/docs/models.astro deleted file mode 100644 index 80688b2..0000000 --- a/src/pages/docs/models.astro +++ /dev/null @@ -1,403 +0,0 @@ ---- -import Docs from '../../layouts/Docs.astro'; -import RateLimits from '../../components/docs/RateLimits.astro'; ---- - - -
-

- // models -

-
- -

Models del cluster.

- -

Los modelos de la comunidad. Todos se acceden por la misma API OpenAI-compatible - con el mismo base URL.

- - -

deepseek-v4-flash - 284B-21B

- -
-
-

- generación de texto y chat -

-

- Modelo MoE de 284B parámetros (21B activos). Contexto de 1M tokens. - Tool calling y reasoning. Cuota mensual de 100M tokens por miembro. -

-
-
-
Tipo
-
MoE (284B total · 21B active)
-
-
-
Cuantización
-
FP8
-
-
-
Contexto
-
1M tokens
-
-
-
Cuota mensual
-
100M tokens / miembro
-
-
-
- -
-

- capacidades -

-
    -
  • - - Tool calling -
  • -
  • - - Reasoning mode -
  • -
  • - - Contexto de 1M tokens -
  • -
  • - - Generación streaming (SSE) -
  • -
-
-
- - -

gemma4 - 26B-A4B

- -
-
-

- generación de texto y chat -

-

- Modelo MoE de 26B parámetros (4B activos), multimodal con visión. Tool calling y reasoning. -

-
-
-
Tipo
-
MoE (26B total · 4B active)
-
-
-
Cuantización
-
FP8
-
-
-
Contexto
-
256K tokens
-
-
-
Sampling
-
temp=0.6, top_p=0.95
-
-
-
Reasoning
-
reasoning_config={}
-
-
-
- -
-

- capacidades -

-
    -
  • - - Tool calling (formato XML) -
  • -
  • - - Reasoning mode -
  • -
  • - - Multimodal (vision / imágenes) -
  • -
  • - - Generación streaming (SSE) -
  • -
-
-
- - -

qwen3.6 - 35B-A3B

- -
-
-

- generación de texto y chat -

-

- El modelo principal. MoE de 35B parámetros, multimodal, con - tool calling y reasoning. -

-
-
-
Tipo
-
MoE (35B total)
-
-
-
Activo por token
-
3B
-
-
-
Cuantización
-
FP8
-
-
-
Contexto
-
256K tokens
-
-
-
Speculative decoding
-
MTP → ~2x throughput
-
-
-
Sampling
-
temp=0.6, top_p=0.95
-
-
-
Reasoning
-
reasoning_config={}
-
-
-
- -
-

- capacidades -

-
    -
  • - - Tool calling (formato XML) -
  • -
  • - - Reasoning mode -
  • -
  • - - Multimodal (vision / imágenes) -
  • -
  • - - Generación streaming (SSE) -
  • -
-
-
- - -

qwen3-embedding - 8B

- -
-
-

- embeddings vectoriales -

-

- Modelo de embedding vectorial. MMTEB score 70.58 — top modelos abiertos. - Soporta 100+ idiomas incluyendo español y código. -

-
-
-
Dimensión
-
4096
-
-
-
Precisión
-
Float32 (CPU)
-
-
-
RPM
-
60
-
-
-
Batch size
-
32
-
-
-
- -
-

- casos de uso -

-
    -
  • - - Similitud cross-lingual (ES↔EN: 0.915) -
  • -
  • - - Búsqueda semántica -
  • -
  • - - Clasificación de texto -
  • -
  • - - RAG / retrieval aumentado -
  • -
-
-
- - -

kokoro - v1.0

- -
-
-

- text-to-speech -

-

- TTS de 82M params con 67 voice packs. Sub-second latency en CPU. -

-
-
-
Latencia
-
< 1s
-
-
-
Partes
-
82M
-
-
-
RPM
-
15
-
-
-
- -
-

- voces disponibles -

-
    -
  • - -
    - af_heart — English (female) -
    -
  • -
  • - -
    - ef_dora — Spanish (female) -
    -
  • -
  • - -
    - em_alex — Spanish (male) -
    -
  • -
  • - - 67 voice packs en total (ver listado completo) -
  • -
-
-
- - -

whisper - large-v3

- -
-
-

- speech-to-text -

-

- STT en CPU con CTranslate2 e INT8. ~1x realtime. 99+ idiomas. -

-
-
-
Tamaño
-
~3 GB (INT8)
-
-
-
WER ES
-
~3.2%
-
-
-
RPM
-
10
-
-
-
- -
-

- capacidades -

-
    -
  • - - Transcripción de audio a texto -
  • -
  • - - 99+ idiomas -
  • -
  • - - Detección de idioma automática -
  • -
  • - - API OpenAI-compatible -
  • -
-
-
- -
-

- limitaciones conocidas -

-
-
-
File size limit — 25 MB
-
- Tamaño máximo por request. Formatos comprimidos (OGG/Opus, MP3) aprovechan - mejor este límite que WAV sin comprimir. -
-
-
-
Timeout — audios > 2 min de duración
-
- Whisper procesa en CPU a ~1x realtime. Para audios de más de ~2 minutos, - el proxy puede devolver un error 524 (timeout) antes de que termine - la transcripción. Usa formatos comprimidos como OGG/Opus y divide - archivos largos en segmentos de ≤ 2 minutos para evitarlo. -
-
-
-
Formatos recomendados
-
- OGG/Opus y MP3 — archivos más pequeños, misma calidad de - transcripción. Un audio de 60 min en OGG/Opus a 48 kbps ocupa ~20 MB vs ~550 MB en WAV. -
-
-
-
- - -
diff --git a/src/styles/global.css b/src/styles/global.css index f7496dc..909ba5f 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -97,7 +97,7 @@ font-weight: 600; } -.docs-content code:not(.code-block code):not([class*="bg-neutral-900"]) { +.docs-content :not(pre) > code:not(.code-block code):not([class*="bg-neutral-900"]) { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 0.8rem; color: rgb(212 212 216); @@ -242,3 +242,146 @@ opacity: 1; transform: translateX(-50%) translateY(0); } + +.docs-content h3 { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.8rem; + line-height: 1.6; + color: rgb(226 232 240); + margin-top: 2rem; + margin-bottom: 0.75rem; +} + +.docs-content a { + color: rgb(167 139 250); + text-decoration: underline; + text-underline-offset: 2px; +} + +.docs-content a:hover { + color: rgb(196 181 253); +} + +.docs-content ul, +.docs-content ol { + margin: 0 0 1.5rem 1.25rem; + color: rgb(203 213 225); +} + +.docs-content li { + margin-bottom: 0.5rem; + line-height: 1.75; +} + +.docs-content blockquote { + margin: 0 0 1.5rem 0; + padding: 1rem 1.25rem; + border: 1px solid rgb(38 38 38 / 0.6); + border-left: 3px solid rgb(139 92 246); + border-radius: 0.75rem; + background: rgb(10 10 10); + box-shadow: 0 0 0 1px rgb(139 92 246 / 0.08) inset; +} + +.docs-content blockquote p:last-child { + margin-bottom: 0; +} + +.docs-content table { + width: 100%; + margin-bottom: 1.5rem; + border-collapse: collapse; + font-size: 0.875rem; + display: block; + overflow-x: auto; +} + +.docs-content thead tr { + border-bottom: 1px solid rgb(38 38 38 / 0.6); +} + +.docs-content tbody tr { + border-bottom: 1px solid rgb(38 38 38 / 0.3); +} + +.docs-content th, +.docs-content td { + padding: 0.75rem 1rem 0.75rem 0; + text-align: left; + vertical-align: top; +} + +.docs-content th { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgb(115 115 115); +} + +.docs-content td { + color: rgb(212 212 216); +} + +.docs-code-block { + margin: 0 0 1.5rem 0; + border: 1px solid rgb(38 38 38 / 0.6); + border-radius: 0.75rem; + background: rgb(10 10 10); + overflow: hidden; +} + +.docs-code-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid rgb(38 38 38 / 0.6); + background: rgb(10 10 10); +} + +.docs-code-label { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgb(167 139 250); +} + +.docs-code-toolbar .copy-btn { + border: 0; + background: transparent; + color: rgb(163 163 163); + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; +} + +.docs-code-toolbar .copy-btn:hover { + color: rgb(226 232 240); +} + +.docs-content pre { + margin: 0; + padding: 1rem 1.25rem; + overflow-x: auto; + background: rgb(10 10 10); +} + +.docs-content pre code { + background: transparent; + padding: 0; + border-radius: 0; +} + +.docs-content img { + display: block; + width: 100%; + height: auto; + margin: 1.5rem 0; + border: 1px solid rgb(38 38 38 / 0.6); + border-radius: 0.75rem; +} From cfe8accf16b9ec72bf54f5ac2eadd1d97ac0d20a Mon Sep 17 00:00:00 2001 From: Nxssie Date: Mon, 25 May 2026 20:11:47 +0100 Subject: [PATCH 02/11] docs: use bash fences for curl examples Co-Authored-By: Claude Opus 4.7 (1M context) --- src/content/docs/api.mdx | 28 ++++++++++++++-------------- src/content/docs/examples.md | 16 ++++++++-------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/content/docs/api.mdx b/src/content/docs/api.mdx index 80b50c5..8652b49 100644 --- a/src/content/docs/api.mdx +++ b/src/content/docs/api.mdx @@ -37,7 +37,7 @@ Listado de los endpoints disponibles. Cada uno enlaza a su sección con `request Todas las peticiones requieren el header `Authorization: Bearer `. La key es personal e intransferible — consulta [Empezar](/docs/getting-started) para obtener la tuya. -```curl +```bash curl https://api.nan.builders/v1/models \ -H "Authorization: Bearer sk-tu-key-aqui" ``` @@ -68,7 +68,7 @@ Sin body. Solo el header de autenticación. ### Ejemplo -```curl +```bash curl https://api.nan.builders/v1/models \ -H "Authorization: Bearer sk-tu-key-aqui" ``` @@ -141,7 +141,7 @@ El campo `reasoning_content` se incluye solo cuando se usa `qwen3.6`. Es opciona ### Ejemplo -```curl +```bash curl https://api.nan.builders/v1/chat/completions \ -H "Authorization: Bearer sk-tu-key-aqui" \ -H "Content-Type: application/json" \ @@ -156,7 +156,7 @@ curl https://api.nan.builders/v1/chat/completions \ Con `stream: true`, la respuesta se entrega como Server-Sent Events. Cada chunk es `data: {...}\n\n` con el delta en `choices[0].delta.content`. El stream termina con `data: [DONE]`. -```curl +```bash curl https://api.nan.builders/v1/chat/completions \ -N \ -H "Authorization: Bearer sk-tu-key-aqui" \ @@ -172,7 +172,7 @@ curl https://api.nan.builders/v1/chat/completions \ `qwen3.6` soporta function calling estándar OpenAI. Cuando el modelo decide invocar una tool, la respuesta incluye `choices[0].message.tool_calls` con `{id, type:"function", function:{name, arguments}}` y `finish_reason: "tool_calls"`. -```curl +```bash curl https://api.nan.builders/v1/chat/completions \ -H "Authorization: Bearer sk-tu-key-aqui" \ -H "Content-Type: application/json" \ @@ -202,7 +202,7 @@ curl https://api.nan.builders/v1/chat/completions \ Tanto `qwen3.6` como `gemma4` aceptan input multimodal. El campo `content` del mensaje pasa de string a un array de partes de tipo `text` y/o `image_url`. -```curl +```bash curl https://api.nan.builders/v1/chat/completions \ -H "Authorization: Bearer sk-tu-key-aqui" \ -H "Content-Type: application/json" \ @@ -229,7 +229,7 @@ Funciona en `qwen3.6` y `gemma4`. **json_object:** -```curl +```bash curl https://api.nan.builders/v1/chat/completions \ -H "Authorization: Bearer sk-tu-key-aqui" \ -H "Content-Type: application/json" \ @@ -244,7 +244,7 @@ curl https://api.nan.builders/v1/chat/completions \ **json_schema (strict):** -```curl +```bash curl https://api.nan.builders/v1/chat/completions \ -H "Authorization: Bearer sk-tu-key-aqui" \ -H "Content-Type: application/json" \ @@ -317,7 +317,7 @@ Ambos modelos soportan reasoning. El toggle se controla con el campo `chat_templ | `qwen3.6` | activo por defecto | | `gemma4` | desactivado por defecto | -```curl +```bash curl https://api.nan.builders/v1/chat/completions \ -H "Authorization: Bearer sk-tu-key-aqui" \ -H "Content-Type: application/json" \ @@ -390,7 +390,7 @@ Endpoint legacy de OpenAI para text completion. Modelo compatible: `qwen3.6`. ### Ejemplo -```curl +```bash curl https://api.nan.builders/v1/completions \ -H "Authorization: Bearer sk-tu-key-aqui" \ -H "Content-Type: application/json" \ @@ -439,7 +439,7 @@ Genera embeddings vectoriales. Modelo compatible: `qwen3-embedding`. Vectores de ### Ejemplo -```curl +```bash curl https://api.nan.builders/v1/embeddings \ -H "Authorization: Bearer sk-tu-key-aqui" \ -H "Content-Type: application/json" \ @@ -469,7 +469,7 @@ Archivo binario de audio en el formato pedido (sin envoltorio JSON). ### Ejemplo -```curl +```bash curl https://api.nan.builders/v1/audio/speech \ -H "Authorization: Bearer sk-tu-key-aqui" \ -H "Content-Type: application/json" \ @@ -528,7 +528,7 @@ Si pasas `timestamp_granularities[]=word`, el campo `words` se llena con `[{word ### Ejemplo -```curl +```bash curl https://api.nan.builders/v1/audio/transcriptions \ -H "Authorization: Bearer sk-tu-key-aqui" \ -F "model=whisper" \ @@ -611,7 +611,7 @@ El array `output` puede contener bloques de tipo `reasoning` (solo `qwen3.6`) y ### Ejemplo -```curl +```bash curl https://api.nan.builders/v1/responses \ -H "Authorization: Bearer sk-tu-key-aqui" \ -H "Content-Type: application/json" \ diff --git a/src/content/docs/examples.md b/src/content/docs/examples.md index 2cb5989..be3246b 100644 --- a/src/content/docs/examples.md +++ b/src/content/docs/examples.md @@ -12,9 +12,9 @@ Ejemplos para conectar a la API con diferentes lenguajes y herramientas. Usa `ht generación de texto y chat -### curl +### bash -```curl +```bash curl https://api.nan.builders/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-tu-key-aqui" \ @@ -236,9 +236,9 @@ Config para `~/.config/zed/settings.json` — incluye inline predictions. embeddings vectoriales -### curl +### bash -```curl +```bash curl https://api.nan.builders/v1/embeddings \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-tu-key-aqui" \ @@ -294,9 +294,9 @@ console.log(embeddings[0].length); // 4096 text-to-speech -### curl +### bash -```curl +```bash curl https://api.nan.builders/v1/audio/speech \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-tu-key-aqui" \ @@ -367,9 +367,9 @@ fs.writeFileSync("output.mp3", buffer); speech-to-text -### curl +### bash -```curl +```bash # Transcribe audio file curl https://api.nan.builders/v1/audio/transcriptions \ -H "Authorization: Bearer sk-tu-key-aqui" \ From 8931b4df0602d31f781c733ff3bf1b2e8c10dc61 Mon Sep 17 00:00:00 2001 From: Nxssie Date: Mon, 25 May 2026 20:23:09 +0100 Subject: [PATCH 03/11] docs(examples): restore curl headings for request-method sections The previous commit incorrectly renamed "### curl" section headings to "### bash" along with code fence languages. The headings denote the request method (curl vs SDK) rather than a syntax highlight language, so they must stay as "curl". Code fences remain as "bash". Co-Authored-By: Claude Opus 4.7 (1M context) --- src/content/docs/examples.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/content/docs/examples.md b/src/content/docs/examples.md index be3246b..657e865 100644 --- a/src/content/docs/examples.md +++ b/src/content/docs/examples.md @@ -12,7 +12,7 @@ Ejemplos para conectar a la API con diferentes lenguajes y herramientas. Usa `ht generación de texto y chat -### bash +### curl ```bash curl https://api.nan.builders/v1/chat/completions \ @@ -236,7 +236,7 @@ Config para `~/.config/zed/settings.json` — incluye inline predictions. embeddings vectoriales -### bash +### curl ```bash curl https://api.nan.builders/v1/embeddings \ @@ -294,7 +294,7 @@ console.log(embeddings[0].length); // 4096 text-to-speech -### bash +### curl ```bash curl https://api.nan.builders/v1/audio/speech \ @@ -367,7 +367,7 @@ fs.writeFileSync("output.mp3", buffer); speech-to-text -### bash +### curl ```bash # Transcribe audio file From c180c40d98a1d7b3962fc897af0c00b8a5af6d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20G=C3=B3mez=20Jim=C3=A9nez?= Date: Tue, 26 May 2026 19:24:37 +0200 Subject: [PATCH 04/11] =?UTF-8?q?feat(docs):=20canonical=20MDX=E2=86=92tex?= =?UTF-8?q?t=20pipeline=20+=20ETag/304=20on=20docs=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #4 review blockers and shape the contract the bot will sync against. - Replace the SSR+htmlToText pipeline with a remark-based mdxToText that works on entry.body. Strips mdxjsEsm imports/exports, converts our MDX components (ModelCard, LimitationsCard, EndpointGrid, FieldList, Callout, RateLimits) and author-written HTML (h1-h4, a, code, strong/b, em/i, br, span) to markdown, then normalises to a stable canonical text. Unknown JSX components throw to fail closed. - Rewrite /api/docs/manifest.json and /api/docs/[slug].md to hash the canonical text. Both endpoints honour If-None-Match (304 with Cache- Control + ETag + X-Content-Hash), validate slug with the shared SAFE_SLUG, and serve Cache-Control: public, max-age=900, s-maxage=900 aligned with the bot's docs_refresh_interval. - Manifest version is sha256 of [(slug, contentHash)] sorted, giving the bot a stable short-circuit. - Drop the ARTICLE-START/END markers from Docs.astro and delete src/lib/htmlToText.ts; they are no longer reachable. - Add nodejs_compat to wrangler.jsonc so the unified/remark stack runs on the Cloudflare Workers dev runtime. - Tests: contentHash (empty/ASCII/UTF-8), docsApi helpers (SAFE_SLUG, Cache-Control, If-None-Match parsing), mdxToText against fixtures per component plus corpus invariants (no imports/exports, no residual HTML, every used component mapped, unknown component throws), and endpoint integration tests for both routes (shape, ordering, stable version/ETag, 304, per-entry hash matches body endpoint). Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 8 +- package.json | 8 +- src/layouts/Docs.astro | 2 - src/lib/__fixtures__/callout.expected.md | 3 + src/lib/__fixtures__/callout.mdx | 5 + src/lib/__fixtures__/composite.expected.md | 11 + src/lib/__fixtures__/composite.mdx | 18 + src/lib/__fixtures__/endpointgrid.expected.md | 2 + src/lib/__fixtures__/endpointgrid.mdx | 8 + src/lib/__fixtures__/fieldlist.expected.md | 2 + src/lib/__fixtures__/fieldlist.mdx | 8 + src/lib/__fixtures__/limitations.expected.md | 9 + src/lib/__fixtures__/limitations.mdx | 15 + src/lib/__fixtures__/modelcard.expected.md | 13 + src/lib/__fixtures__/modelcard.mdx | 18 + src/lib/__fixtures__/ratelimits.expected.md | 2 + src/lib/__fixtures__/ratelimits.mdx | 3 + .../__fixtures__/raw-html-heading.expected.md | 9 + src/lib/__fixtures__/raw-html-heading.mdx | 9 + .../__fixtures__/raw-html-inline.expected.md | 1 + src/lib/__fixtures__/raw-html-inline.mdx | 1 + src/lib/contentHash.test.ts | 23 + src/lib/docsApi.test.ts | 59 +++ src/lib/docsApi.ts | 19 + src/lib/htmlToText.ts | Bin 4464 -> 0 bytes src/lib/mdxToText.test.ts | 85 ++++ src/lib/mdxToText.ts | 453 ++++++++++++++++++ src/pages/api/docs/[slug].md.ts | 55 +-- src/pages/api/docs/manifest.json.ts | 47 +- src/tests/api/docs.test.ts | 123 +++++ src/tests/helpers/docsApiServer.ts | 29 ++ vitest.config.ts | 1 + wrangler.jsonc | 2 +- 33 files changed, 990 insertions(+), 61 deletions(-) create mode 100644 src/lib/__fixtures__/callout.expected.md create mode 100644 src/lib/__fixtures__/callout.mdx create mode 100644 src/lib/__fixtures__/composite.expected.md create mode 100644 src/lib/__fixtures__/composite.mdx create mode 100644 src/lib/__fixtures__/endpointgrid.expected.md create mode 100644 src/lib/__fixtures__/endpointgrid.mdx create mode 100644 src/lib/__fixtures__/fieldlist.expected.md create mode 100644 src/lib/__fixtures__/fieldlist.mdx create mode 100644 src/lib/__fixtures__/limitations.expected.md create mode 100644 src/lib/__fixtures__/limitations.mdx create mode 100644 src/lib/__fixtures__/modelcard.expected.md create mode 100644 src/lib/__fixtures__/modelcard.mdx create mode 100644 src/lib/__fixtures__/ratelimits.expected.md create mode 100644 src/lib/__fixtures__/ratelimits.mdx create mode 100644 src/lib/__fixtures__/raw-html-heading.expected.md create mode 100644 src/lib/__fixtures__/raw-html-heading.mdx create mode 100644 src/lib/__fixtures__/raw-html-inline.expected.md create mode 100644 src/lib/__fixtures__/raw-html-inline.mdx create mode 100644 src/lib/contentHash.test.ts create mode 100644 src/lib/docsApi.test.ts create mode 100644 src/lib/docsApi.ts delete mode 100644 src/lib/htmlToText.ts create mode 100644 src/lib/mdxToText.test.ts create mode 100644 src/lib/mdxToText.ts create mode 100644 src/tests/api/docs.test.ts create mode 100644 src/tests/helpers/docsApiServer.ts diff --git a/package-lock.json b/package-lock.json index f5272fc..9df7b7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,14 @@ "astro-i18n": "^2.2.4", "preact": "^10.29.0", "rehype-pretty-code": "^0.14.3", + "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", "shiki": "^4.0.2", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0" }, "devDependencies": { "@astrojs/check": "^0.9.8", diff --git a/package.json b/package.json index df4cedb..aeeb293 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,14 @@ "astro-i18n": "^2.2.4", "preact": "^10.29.0", "rehype-pretty-code": "^0.14.3", + "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", "shiki": "^4.0.2", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0" }, "devDependencies": { "@astrojs/check": "^0.9.8", diff --git a/src/layouts/Docs.astro b/src/layouts/Docs.astro index d8d6727..5138a44 100644 --- a/src/layouts/Docs.astro +++ b/src/layouts/Docs.astro @@ -149,9 +149,7 @@ const breadcrumbSegments = pathParts.map((part, i) => {
- -
diff --git a/src/lib/__fixtures__/callout.expected.md b/src/lib/__fixtures__/callout.expected.md new file mode 100644 index 0000000..ddb1684 --- /dev/null +++ b/src/lib/__fixtures__/callout.expected.md @@ -0,0 +1,3 @@ +> \[!INFO] Heads up +> +> Visit [example](https://example.com) for more info on `config.json`. \ No newline at end of file diff --git a/src/lib/__fixtures__/callout.mdx b/src/lib/__fixtures__/callout.mdx new file mode 100644 index 0000000..f0b062f --- /dev/null +++ b/src/lib/__fixtures__/callout.mdx @@ -0,0 +1,5 @@ +import Callout from '../../components/docs/Callout.astro'; + + + Visit example for more info on config.json. + diff --git a/src/lib/__fixtures__/composite.expected.md b/src/lib/__fixtures__/composite.expected.md new file mode 100644 index 0000000..0a0ebed --- /dev/null +++ b/src/lib/__fixtures__/composite.expected.md @@ -0,0 +1,11 @@ +# Composite + +Intro paragraph with `code`. + +> \[!INFO] Tip +> +> Plain prose inside the callout. + +## Endpoints + +- [X](#x) - `GET /v1/x` \ No newline at end of file diff --git a/src/lib/__fixtures__/composite.mdx b/src/lib/__fixtures__/composite.mdx new file mode 100644 index 0000000..15bc71e --- /dev/null +++ b/src/lib/__fixtures__/composite.mdx @@ -0,0 +1,18 @@ +import Callout from '../../components/docs/Callout.astro'; +import EndpointGrid from '../../components/docs/EndpointGrid.astro'; + +# Composite + +Intro paragraph with `code`. + + + Plain prose inside the callout. + + +

Endpoints

+ + diff --git a/src/lib/__fixtures__/endpointgrid.expected.md b/src/lib/__fixtures__/endpointgrid.expected.md new file mode 100644 index 0000000..42df3c3 --- /dev/null +++ b/src/lib/__fixtures__/endpointgrid.expected.md @@ -0,0 +1,2 @@ +- [Get A](#a) - `GET /v1/a` +- [Post B](#b) - `POST /v1/b` \ No newline at end of file diff --git a/src/lib/__fixtures__/endpointgrid.mdx b/src/lib/__fixtures__/endpointgrid.mdx new file mode 100644 index 0000000..d25e364 --- /dev/null +++ b/src/lib/__fixtures__/endpointgrid.mdx @@ -0,0 +1,8 @@ +import EndpointGrid from '../../components/docs/EndpointGrid.astro'; + + diff --git a/src/lib/__fixtures__/fieldlist.expected.md b/src/lib/__fixtures__/fieldlist.expected.md new file mode 100644 index 0000000..2d3827f --- /dev/null +++ b/src/lib/__fixtures__/fieldlist.expected.md @@ -0,0 +1,2 @@ +- `model` - *string · required* - The model name. +- `temperature` - *number · optional* - Sampling temperature, default `0.6`. \ No newline at end of file diff --git a/src/lib/__fixtures__/fieldlist.mdx b/src/lib/__fixtures__/fieldlist.mdx new file mode 100644 index 0000000..18862e8 --- /dev/null +++ b/src/lib/__fixtures__/fieldlist.mdx @@ -0,0 +1,8 @@ +import FieldList from '../../components/docs/FieldList.astro'; + +0.6
.' }, + ]} +/> diff --git a/src/lib/__fixtures__/limitations.expected.md b/src/lib/__fixtures__/limitations.expected.md new file mode 100644 index 0000000..16cbd66 --- /dev/null +++ b/src/lib/__fixtures__/limitations.expected.md @@ -0,0 +1,9 @@ +### known limits + +**limit a** + +body of limit a with `code` and **bold**. + +**limit b** + +body of limit b. \ No newline at end of file diff --git a/src/lib/__fixtures__/limitations.mdx b/src/lib/__fixtures__/limitations.mdx new file mode 100644 index 0000000..c28b76f --- /dev/null +++ b/src/lib/__fixtures__/limitations.mdx @@ -0,0 +1,15 @@ +import LimitationsCard from '../../components/docs/LimitationsCard.astro'; + +code and bold.', + }, + { + title: 'limit b', + body: 'body of limit b.', + }, + ]} +/> diff --git a/src/lib/__fixtures__/modelcard.expected.md b/src/lib/__fixtures__/modelcard.expected.md new file mode 100644 index 0000000..39b33e3 --- /dev/null +++ b/src/lib/__fixtures__/modelcard.expected.md @@ -0,0 +1,13 @@ +### test-model - 1B + +Test model description. + +**capabilities** + +- Param: 1B +- Context: 8K + +**use cases** + +- item 1 +- item 2 \ No newline at end of file diff --git a/src/lib/__fixtures__/modelcard.mdx b/src/lib/__fixtures__/modelcard.mdx new file mode 100644 index 0000000..1b3d0d3 --- /dev/null +++ b/src/lib/__fixtures__/modelcard.mdx @@ -0,0 +1,18 @@ +import ModelCard from '../../components/docs/ModelCard.astro'; + + diff --git a/src/lib/__fixtures__/ratelimits.expected.md b/src/lib/__fixtures__/ratelimits.expected.md new file mode 100644 index 0000000..bd769c6 --- /dev/null +++ b/src/lib/__fixtures__/ratelimits.expected.md @@ -0,0 +1,2 @@ +- Requests / min: 100 rpm +- Paralelo máximo: 5 concurrentes \ No newline at end of file diff --git a/src/lib/__fixtures__/ratelimits.mdx b/src/lib/__fixtures__/ratelimits.mdx new file mode 100644 index 0000000..680a3b5 --- /dev/null +++ b/src/lib/__fixtures__/ratelimits.mdx @@ -0,0 +1,3 @@ +import RateLimits from '../../components/docs/RateLimits.astro'; + + diff --git a/src/lib/__fixtures__/raw-html-heading.expected.md b/src/lib/__fixtures__/raw-html-heading.expected.md new file mode 100644 index 0000000..bb27012 --- /dev/null +++ b/src/lib/__fixtures__/raw-html-heading.expected.md @@ -0,0 +1,9 @@ +# Top + +## Section heading + +Body text follows. + +### Subsection + +More content. \ No newline at end of file diff --git a/src/lib/__fixtures__/raw-html-heading.mdx b/src/lib/__fixtures__/raw-html-heading.mdx new file mode 100644 index 0000000..9cefae5 --- /dev/null +++ b/src/lib/__fixtures__/raw-html-heading.mdx @@ -0,0 +1,9 @@ +# Top + +

Section heading

+ +Body text follows. + +

Subsection

+ +More content. diff --git a/src/lib/__fixtures__/raw-html-inline.expected.md b/src/lib/__fixtures__/raw-html-inline.expected.md new file mode 100644 index 0000000..e92b3e4 --- /dev/null +++ b/src/lib/__fixtures__/raw-html-inline.expected.md @@ -0,0 +1 @@ +Visit [example](https://example.com) and check `npm install`. Use **bold** and *italic* styles. \ No newline at end of file diff --git a/src/lib/__fixtures__/raw-html-inline.mdx b/src/lib/__fixtures__/raw-html-inline.mdx new file mode 100644 index 0000000..34c85f5 --- /dev/null +++ b/src/lib/__fixtures__/raw-html-inline.mdx @@ -0,0 +1 @@ +Visit example and check npm install. Use bold and italic styles. diff --git a/src/lib/contentHash.test.ts b/src/lib/contentHash.test.ts new file mode 100644 index 0000000..bd818ba --- /dev/null +++ b/src/lib/contentHash.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { sha256Hex } from './contentHash'; + +describe('sha256Hex', () => { + it('hashes the empty string deterministically', async () => { + expect(await sha256Hex('')).toBe( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ); + }); + + it('hashes ASCII text deterministically', async () => { + expect(await sha256Hex('hello world')).toBe( + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', + ); + }); + + it('hashes UTF-8 multibyte text deterministically', async () => { + // "héllo wörld" with é=0xC3 0xA9 and ö=0xC3 0xB6 + expect(await sha256Hex('héllo wörld')).toBe( + 'a1003f7d04a4115711d0b48a2eaf1359ce565d2d2a6fd65098dfcffadeeef59f', + ); + }); +}); diff --git a/src/lib/docsApi.test.ts b/src/lib/docsApi.test.ts new file mode 100644 index 0000000..7963498 --- /dev/null +++ b/src/lib/docsApi.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { DOCS_CACHE_CONTROL, SAFE_SLUG, ifNoneMatchMatches } from './docsApi'; + +describe('SAFE_SLUG', () => { + it('accepts safe slugs and rejects unsafe ones', () => { + const cases: Array<[string, boolean]> = [ + ['api', true], + ['getting-started', true], + ['a', true], + ['a0', true], + ['0-bad', true], + ['a'.repeat(64), true], + ['-bad', false], + ['Bad', false], + ['', false], + ['../etc', false], + ['api/', false], + ['a'.repeat(65), false], + ]; + for (const [slug, expected] of cases) { + expect(SAFE_SLUG.test(slug), slug).toBe(expected); + } + }); +}); + +describe('DOCS_CACHE_CONTROL', () => { + it('is set to 900s for both browser and shared caches', () => { + expect(DOCS_CACHE_CONTROL).toBe('public, max-age=900, s-maxage=900'); + }); +}); + +describe('ifNoneMatchMatches', () => { + const etag = '"sha256:abc"'; + + it('returns false for missing header', () => { + expect(ifNoneMatchMatches(null, etag)).toBe(false); + }); + + it('returns true for wildcard', () => { + expect(ifNoneMatchMatches('*', etag)).toBe(true); + }); + + it('matches exact value', () => { + expect(ifNoneMatchMatches(etag, etag)).toBe(true); + }); + + it('matches against a comma-separated list', () => { + expect(ifNoneMatchMatches(`"other", ${etag}, "x"`, etag)).toBe(true); + }); + + it('ignores W/ weak prefix on header and etag', () => { + expect(ifNoneMatchMatches(`W/${etag}`, etag)).toBe(true); + expect(ifNoneMatchMatches(etag, `W/${etag}`)).toBe(true); + }); + + it('does not match unrelated tags', () => { + expect(ifNoneMatchMatches('"sha256:other"', etag)).toBe(false); + }); +}); diff --git a/src/lib/docsApi.ts b/src/lib/docsApi.ts new file mode 100644 index 0000000..7c06c51 --- /dev/null +++ b/src/lib/docsApi.ts @@ -0,0 +1,19 @@ +export const SAFE_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/; +export const DOCS_CACHE_CONTROL = 'public, max-age=900, s-maxage=900'; + +export function quoteEtag(value: string): string { + return `"${value}"`; +} + +export function ifNoneMatchMatches(header: string | null, etag: string): boolean { + if (!header) return false; + if (header.trim() === '*') return true; + + const normalized = etag.startsWith('W/') ? etag.slice(2) : etag; + + return header + .split(',') + .map((part) => part.trim()) + .map((part) => (part.startsWith('W/') ? part.slice(2) : part)) + .some((part) => part === normalized); +} diff --git a/src/lib/htmlToText.ts b/src/lib/htmlToText.ts deleted file mode 100644 index 2b9f2adf8f92f77f6c7e4a82e83c8e3d4e5e5ab6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4464 zcmb7H-EP}96z+AN;?xal$+4`aEe0%g9JFbQ2Fu!_4KQGq8`BnT38hF)q!(wlfnMxp z*Xt7u+kj!fUSM~7ls?JMp)CKza+cYEEsBTd=R3dbE2#~fygENUKRtPO0B?C;WnojB zESAGMp< ziZ=hqH~Ekjf7yk}SZEpj_zSLQPj+?&BiT2xk`N*YAwQ8OHZj)@8j<7R77rGUjGIv= zL8Ea1jf|&)^||BUzc+T;54cfP7w-idewou zhlendAs@t&hj6rrl==1nB(D2pXSM5l?_=uo)345%NrfRuHO7tTq{E38YMGuEC+iN>8Q0@T$XQ6dSHn zOrxAzUkQNk6oy0y-D{WX&duz{f)tbj!oJz-+=dPcaL09Tk;4 zvx!CVLm!GE!Vnd)nX;saJrggmmhjMr6Ntj|%IZXog7rSI;9AejYncS4#jvo4T(yS|Ni z+{|g|RJXu5w=NFH$Zr`ujdY|V$I5ZRgZ+YfUR}C(j$c1Nxp;Q=`uMAh-3gwa-I;To zQRBl(gum#Mmz;$Nt1c@C(SB)^Mf*7{HmZz`BWve9*qy9oj$>RhNOo=2TaRqN89glL z{csEK|Hu4MIrB%`nEyZSkIT6~-pc(3_PH0%Vr@!YAmUP4BHoncmBC*Qe&9PJF;&9N zxl{vLUe)JRg$j8x7Lekip9%h5v8Hd*PqlyZf78CJKzKU#1j zvRswtT-#Pdqmm#v!3m8YhHhD8hl^IVRzE|t32Uvk5!JQY0h9w9hJ_XDe8Ww?8(g<# zO5lfL&ED&6!f%G65WDH3bJi0ahegABp5{2xF@rNxrY>~@oHL~~w%3ut0?R_8gn`O{ zCp{kKWr1eC>$z*9Lfo2_Cc2H4rW`p_j-0s_a$9%?zRCUrIm`qEM(`J*O;)5wc`V*^u)wEiA4QOU6CwKF^Q@||KYjuw@w~#>qmDh@P9j|5^i30wy-9>nX;x4H zu4#rDa+Bv##yx7{8IAxHC^#@-gMNAb^%-?u=%GqD)8M0LOMObxGCt(vLe1;fl!d0p z`P{@;2CCxB$_(0uz;yNkX~)1VwIYy{NA=m9Vx0ytN}Pqrw_F>AGa|K3&rZN=(_e{r zI9lnO_I3kD`Av!LBevHqdvN>J2ooi9q}$2IA{4xlEhWk#QE^RMzoo2p&)W|C8v>hb zIW4Z`CtFERR)@TEPf(d+f{HReC?-H8sM0!*{eogXW3B3G+(rR9ahe-uSsg*QoliTn zjXsJl@}lGLWEyXgVz;(P z*r?sLa{d%5r8dyU3h!cw!Xfb*w2KRTcYZIL1>JI48`EeJV#`T0&n?Rcs!O-ccK!t| C3QSG_ diff --git a/src/lib/mdxToText.test.ts b/src/lib/mdxToText.test.ts new file mode 100644 index 0000000..6167c82 --- /dev/null +++ b/src/lib/mdxToText.test.ts @@ -0,0 +1,85 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { mdxToText } from './mdxToText'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(here, '__fixtures__'); +const docsDir = path.join(here, '..', 'content', 'docs'); + +const KNOWN_COMPONENTS = [ + 'ModelCard', + 'LimitationsCard', + 'EndpointGrid', + 'FieldList', + 'Callout', + 'RateLimits', +]; + +const FIXTURES = [ + 'modelcard', + 'limitations', + 'endpointgrid', + 'fieldlist', + 'callout', + 'ratelimits', + 'raw-html-heading', + 'raw-html-inline', + 'composite', +] as const; + +function stripFrontmatter(raw: string): string { + return raw.replace(/^---[\s\S]*?\n---\s*\n/, ''); +} + +describe('mdxToText fixtures', () => { + for (const name of FIXTURES) { + it(`renders ${name}.mdx to the expected canonical text`, async () => { + const input = fs.readFileSync(path.join(fixturesDir, `${name}.mdx`), 'utf8'); + const expected = fs.readFileSync(path.join(fixturesDir, `${name}.expected.md`), 'utf8'); + const actual = await mdxToText(stripFrontmatter(input)); + expect(actual).toBe(expected); + }); + } +}); + +describe('mdxToText invariants on docs corpus', () => { + const files = fs + .readdirSync(docsDir) + .filter((f) => /\.(md|mdx)$/.test(f)); + + for (const file of files) { + it(`${file}: contains no MDX import/export lines and no residual HTML tags`, async () => { + const raw = fs.readFileSync(path.join(docsDir, file), 'utf8'); + const out = await mdxToText(stripFrontmatter(raw)); + const codeFenceFree = out.replace(/```[\s\S]*?```/g, ''); + expect(codeFenceFree).not.toMatch(/^import\s/m); + expect(codeFenceFree).not.toMatch(/^export\s/m); + // No residual HTML tags for the supported set. + expect(codeFenceFree).not.toMatch(/<\/?h[1-4]\b/i); + expect(codeFenceFree).not.toMatch(/<\/?a\b/i); + expect(codeFenceFree).not.toMatch(/<\/?(strong|b|em|i)\b/i); + expect(codeFenceFree).not.toMatch(/<\/?code\b/i); + }); + } + + it('every custom MDX component used in src/content/docs is mapped', () => { + const used = new Set(); + for (const file of files) { + const raw = fs.readFileSync(path.join(docsDir, file), 'utf8'); + const matches = raw.matchAll(/<([A-Z][A-Za-z0-9]*)\b/g); + for (const m of matches) used.add(m[1]); + } + for (const name of used) { + expect(KNOWN_COMPONENTS, `Unmapped component <${name}>`).toContain(name); + } + }); +}); + +describe('mdxToText error cases', () => { + it('throws for an unknown MDX component', async () => { + const input = '\n'; + await expect(mdxToText(input)).rejects.toThrow(/UnknownThing/); + }); +}); diff --git a/src/lib/mdxToText.ts b/src/lib/mdxToText.ts new file mode 100644 index 0000000..bca6950 --- /dev/null +++ b/src/lib/mdxToText.ts @@ -0,0 +1,453 @@ +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkGfm from 'remark-gfm'; +import remarkMdx from 'remark-mdx'; +import remarkStringify from 'remark-stringify'; + +const KNOWN_COMPONENTS = new Set([ + 'ModelCard', + 'LimitationsCard', + 'EndpointGrid', + 'FieldList', + 'Callout', + 'RateLimits', +]); + +const AUTHOR_HTML_BLOCK_TAGS = new Set(['h1', 'h2', 'h3', 'h4']); +const AUTHOR_HTML_INLINE_TAGS = new Set(['a', 'code', 'strong', 'b', 'em', 'i', 'br']); + +const ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + '#39': "'", + nbsp: ' ', + le: '≤', + ge: '≥', + ndash: '–', + mdash: '—', + hellip: '…', +}; + +function decodeEntities(s: string): string { + return s + .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))) + .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10))) + .replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (m, n) => (ENTITIES[n] !== undefined ? ENTITIES[n] : m)); +} + +export function stripInlineHtml(input: string): string { + let s = input; + s = s.replace(//gi, '\n'); + s = s.replace(/]*)>([\s\S]*?)<\/a>/gi, (_m, a, t) => { + const hrefMatch = a.match(/href\s*=\s*"([^"]*)"/i); + const href = hrefMatch ? hrefMatch[1] : null; + const text = stripInlineHtml(t).trim(); + return href ? `[${text}](${href})` : text; + }); + s = s.replace(/]*>([\s\S]*?)<\/code>/gi, (_m, t) => `\`${stripInlineHtml(t).replace(/`/g, '')}\``); + s = s.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/(strong|b)>/gi, (_m, _t, t) => `**${stripInlineHtml(t).trim()}**`); + s = s.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/(em|i)>/gi, (_m, _t, t) => `*${stripInlineHtml(t).trim()}*`); + s = s.replace(/]*>([\s\S]*?)<\/span>/gi, (_m, t) => stripInlineHtml(t)); + s = decodeEntities(s); + return s; +} + +export function htmlNodeToMarkdown(input: string): string { + let s = input; + s = s.replace(/]*>([\s\S]*?)<\/h1>/gi, (_m, t) => `\n\n# ${stripInlineHtml(t).trim()}\n\n`); + s = s.replace(/]*>([\s\S]*?)<\/h2>/gi, (_m, t) => `\n\n## ${stripInlineHtml(t).trim()}\n\n`); + s = s.replace(/]*>([\s\S]*?)<\/h3>/gi, (_m, t) => `\n\n### ${stripInlineHtml(t).trim()}\n\n`); + s = s.replace(/]*>([\s\S]*?)<\/h4>/gi, (_m, t) => `\n\n#### ${stripInlineHtml(t).trim()}\n\n`); + return stripInlineHtml(s); +} + +export function astToValue(node: unknown): unknown { + if (!node || typeof node !== 'object') return undefined; + const n = node as { type: string; [k: string]: unknown }; + switch (n.type) { + case 'Literal': + return (n as { value: unknown }).value; + case 'ArrayExpression': + return ((n as { elements: unknown[] }).elements || []).map((e) => astToValue(e)); + case 'ObjectExpression': { + const obj: Record = {}; + const props = (n as { properties: unknown[] }).properties || []; + for (const p of props) { + const prop = p as { type: string; key: { type: string; name?: string; value?: unknown }; value: unknown }; + if (prop.type !== 'Property') continue; + let key: string; + if (prop.key.type === 'Identifier') key = prop.key.name as string; + else key = String(astToValue(prop.key)); + obj[key] = astToValue(prop.value); + } + return obj; + } + case 'TemplateLiteral': { + const quasis = ((n as { quasis: { value: { cooked: string } }[] }).quasis || []); + return quasis.map((q) => q.value.cooked).join(''); + } + case 'UnaryExpression': { + const op = (n as { operator: string; argument: unknown }).operator; + const arg = astToValue((n as { argument: unknown }).argument); + if (op === '-' && typeof arg === 'number') return -arg; + if (op === '+' && typeof arg === 'number') return arg; + if (op === '!') return !arg; + return undefined; + } + case 'Identifier': { + const name = (n as { name: string }).name; + if (name === 'undefined') return undefined; + return undefined; + } + default: + return undefined; + } +} + +export function getAttr(node: { attributes?: unknown[] }, name: string): unknown { + if (!Array.isArray(node.attributes)) return undefined; + for (const a of node.attributes) { + const attr = a as { + type: string; + name?: string; + value?: unknown; + }; + if (attr.type !== 'mdxJsxAttribute') continue; + if (attr.name !== name) continue; + if (typeof attr.value === 'string') return attr.value; + if (attr.value == null) return true; + const v = attr.value as { type: string; data?: { estree?: { body?: unknown[] } } }; + if (v.type === 'mdxJsxAttributeValueExpression') { + const body = v.data?.estree?.body; + if (Array.isArray(body) && body[0]) { + const expr = (body[0] as { expression?: unknown }).expression; + return astToValue(expr); + } + } + } + return undefined; +} + +function getName(node: { name?: string | null }): string { + return node.name || ''; +} + +function textOfChildren(node: { children?: unknown[] }): string { + if (!Array.isArray(node.children)) return ''; + return node.children + .map((c) => { + const child = c as { type: string; value?: string; children?: unknown[] }; + if (child.type === 'text') return child.value || ''; + if (Array.isArray(child.children)) return textOfChildren(child); + return ''; + }) + .join(''); +} + +const stringifier = unified() + .use(remarkGfm) + .use(remarkStringify, { + bullet: '-', + emphasis: '*', + strong: '*', + fences: true, + rule: '-', + listItemIndent: 'one', + }); + +function stringifyBlockChildren(children: unknown[]): string { + const root = { type: 'root', children: (children || []) as unknown[] } as unknown; + return (stringifier.stringify(root as never) as string).replace(/\r\n/g, '\n'); +} + +function stringifyInlineChildren(children: unknown[]): string { + const root = { + type: 'root', + children: [{ type: 'paragraph', children: (children || []) as unknown[] }], + } as unknown; + return (stringifier.stringify(root as never) as string).replace(/\r\n/g, '\n').trim(); +} + +function mdToBlockChildren(md: string): unknown[] { + const proc = unified().use(remarkParse).use(remarkGfm); + const tree = proc.parse(md) as { children: unknown[] }; + return tree.children; +} + +function mdToInlineChildren(md: string): unknown[] { + const proc = unified().use(remarkParse).use(remarkGfm); + const tree = proc.parse(md) as { children: unknown[] }; + const first = tree.children[0] as { type?: string; children?: unknown[] } | undefined; + if (!first || first.type !== 'paragraph' || !Array.isArray(first.children)) { + return [{ type: 'text', value: md }]; + } + return first.children; +} + +interface MdxNode { + type: string; + name?: string | null; + attributes?: unknown[]; + children?: unknown[]; +} + +function componentToBlockMd(node: MdxNode): string { + const name = getName(node); + switch (name) { + case 'ModelCard': + return modelCardToMd(node); + case 'LimitationsCard': + return limitationsCardToMd(node); + case 'EndpointGrid': + return endpointGridToMd(node); + case 'FieldList': + return fieldListToMd(node); + case 'Callout': + return calloutToMd(node); + case 'RateLimits': + return rateLimitsToMd(); + default: + throw new Error(`Unknown MDX block component: <${name || '?'}>`); + } +} + +function modelCardToMd(node: MdxNode): string { + const name = String(getAttr(node, 'name') ?? ''); + const tag = getAttr(node, 'tag'); + const leftLabel = String(getAttr(node, 'leftLabel') ?? ''); + const rightLabel = String(getAttr(node, 'rightLabel') ?? ''); + const description = String(getAttr(node, 'description') ?? ''); + const specs = (getAttr(node, 'specs') as Array<{ label?: string; value?: string }> | undefined) || []; + const items = (getAttr(node, 'items') as string[] | undefined) || []; + + const heading = tag ? `### ${name} - ${tag}` : `### ${name}`; + const lines: string[] = [heading, '', description, '', `**${leftLabel}**`, '']; + for (const s of specs) { + const label = String(s.label ?? ''); + const value = stripInlineHtml(String(s.value ?? '')); + lines.push(`- ${label}: ${value}`); + } + lines.push(''); + lines.push(`**${rightLabel}**`); + lines.push(''); + for (const it of items) { + lines.push(`- ${stripInlineHtml(String(it))}`); + } + lines.push(''); + return lines.join('\n'); +} + +function limitationsCardToMd(node: MdxNode): string { + const title = String(getAttr(node, 'title') ?? 'limitaciones conocidas'); + const items = (getAttr(node, 'items') as Array<{ title?: string; body?: string }> | undefined) || []; + + const lines: string[] = [`### ${title}`, '']; + for (const it of items) { + const t = stripInlineHtml(String(it.title ?? '')); + const body = stripInlineHtml(String(it.body ?? '')); + lines.push(`**${t}**`); + lines.push(''); + lines.push(body); + lines.push(''); + } + return lines.join('\n'); +} + +function endpointGridToMd(node: MdxNode): string { + const items = + (getAttr(node, 'items') as Array<{ href?: string; title?: string; method?: string; path?: string }> | undefined) || + []; + const lines: string[] = []; + for (const it of items) { + const title = String(it.title ?? ''); + const href = String(it.href ?? ''); + const method = String(it.method ?? ''); + const path = String(it.path ?? ''); + lines.push(`- [${title}](${href}) - \`${method} ${path}\``); + } + lines.push(''); + return lines.join('\n'); +} + +function fieldListToMd(node: MdxNode): string { + const fields = + (getAttr(node, 'fields') as Array<{ name?: string; type?: string; description?: string }> | undefined) || []; + const lines: string[] = []; + for (const f of fields) { + const name = String(f.name ?? ''); + const type = String(f.type ?? ''); + const description = stripInlineHtml(String(f.description ?? '')); + lines.push(`- \`${name}\` - *${type}* - ${description}`); + } + lines.push(''); + return lines.join('\n'); +} + +function calloutToMd(node: MdxNode): string { + const title = String(getAttr(node, 'title') ?? ''); + const variant = String(getAttr(node, 'variant') ?? 'info').toUpperCase(); + const innerMd = stringifyBlockChildren(node.children || []).trim(); + if (!innerMd) { + return `> [!${variant}] ${title}\n`; + } + const quoted = innerMd + .split('\n') + .map((line) => (line.length ? `> ${line}` : '>')) + .join('\n'); + return `> [!${variant}] ${title}\n>\n${quoted}\n`; +} + +function rateLimitsToMd(): string { + return ['- Requests / min: 100 rpm', '- Paralelo máximo: 5 concurrentes', ''].join('\n'); +} + +function htmlTagToBlockMd(node: MdxNode): string { + const name = getName(node).toLowerCase(); + if (AUTHOR_HTML_BLOCK_TAGS.has(name)) { + const level = Number(name.slice(1)); + const hashes = '#'.repeat(level); + const inner = stringifyInlineChildren(node.children || []).trim(); + return `${hashes} ${inner}`; + } + // Inline tags occurring at block level — wrap as paragraph. + return htmlTagToInlineMd(node); +} + +function htmlTagToInlineMd(node: MdxNode): string { + const name = getName(node).toLowerCase(); + const innerText = stringifyInlineChildren(node.children || []).trim(); + switch (name) { + case 'a': { + const href = String(getAttr(node, 'href') ?? ''); + return href ? `[${innerText}](${href})` : innerText; + } + case 'code': + return `\`${textOfChildren(node).replace(/`/g, '')}\``; + case 'strong': + case 'b': + return `**${innerText}**`; + case 'em': + case 'i': + return `*${innerText}*`; + case 'br': + return '\n'; + default: + throw new Error(`Unknown MDX inline component: <${name || '?'}>`); + } +} + +function isMdxJsxFlowComponent(t: string): boolean { + return t === 'mdxJsxFlowElement'; +} + +function isMdxJsxTextComponent(t: string): boolean { + return t === 'mdxJsxTextElement'; +} + +function isMdxEsm(t: string): boolean { + return t === 'mdxjsEsm'; +} + +function isMdxExpression(t: string): boolean { + return t === 'mdxFlowExpression' || t === 'mdxTextExpression'; +} + +function promoteHeadingParagraphs(root: { children?: unknown[] }): void { + if (!Array.isArray(root.children)) return; + const out: unknown[] = []; + for (const c of root.children) { + const child = c as MdxNode & { type?: string }; + if ( + child.type === 'paragraph' && + Array.isArray(child.children) && + child.children.length === 1 + ) { + const only = child.children[0] as MdxNode; + if (only.type === 'mdxJsxTextElement') { + const name = getName(only).toLowerCase(); + if (AUTHOR_HTML_BLOCK_TAGS.has(name)) { + out.push({ ...only, type: 'mdxJsxFlowElement' }); + continue; + } + } + } + out.push(c); + } + root.children = out; +} + +function transformTree(root: { children?: unknown[] }): void { + promoteHeadingParagraphs(root); + + function process(node: { type?: string; children?: unknown[] }): unknown[] { + if (!Array.isArray(node.children)) return []; + const out: unknown[] = []; + for (const c of node.children) { + const child = c as MdxNode; + // Recurse first (post-order). + if (Array.isArray(child.children)) { + const newChildren = process(child); + child.children = newChildren; + } + + if (isMdxEsm(child.type) || isMdxExpression(child.type)) { + continue; + } + + if (child.type === 'html') { + const md = htmlNodeToMarkdown((child as unknown as { value: string }).value); + out.push(...mdToBlockChildren(md)); + continue; + } + + if (isMdxJsxFlowComponent(child.type)) { + const name = getName(child); + if (KNOWN_COMPONENTS.has(name)) { + const md = componentToBlockMd(child); + out.push(...mdToBlockChildren(md)); + } else if (AUTHOR_HTML_BLOCK_TAGS.has(name.toLowerCase()) || AUTHOR_HTML_INLINE_TAGS.has(name.toLowerCase())) { + const md = htmlTagToBlockMd(child); + out.push(...mdToBlockChildren(md)); + } else { + throw new Error(`Unknown MDX block component: <${name || '?'}>`); + } + continue; + } + + if (isMdxJsxTextComponent(child.type)) { + const name = getName(child); + if (AUTHOR_HTML_INLINE_TAGS.has(name.toLowerCase())) { + const md = htmlTagToInlineMd(child); + out.push(...mdToInlineChildren(md)); + } else if (KNOWN_COMPONENTS.has(name)) { + throw new Error(`MDX component <${name}> is not allowed in inline context`); + } else { + throw new Error(`Unknown MDX inline component: <${name || '?'}>`); + } + continue; + } + + out.push(child); + } + return out; + } + + root.children = process(root as { type?: string; children?: unknown[] }); +} + +export function normalizeCanonicalText(text: string): string { + let s = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + s = s.replace(/\n{3,}/g, '\n\n'); + return s.trim(); +} + +export async function mdxToText(body: string): Promise { + const parser = unified().use(remarkParse).use(remarkGfm).use(remarkMdx); + const tree = parser.parse(body) as { children: unknown[] }; + transformTree(tree as { children?: unknown[] }); + const out = stringifier.stringify(tree as never) as string; + return normalizeCanonicalText(out); +} diff --git a/src/pages/api/docs/[slug].md.ts b/src/pages/api/docs/[slug].md.ts index 46d1aa3..c210834 100644 --- a/src/pages/api/docs/[slug].md.ts +++ b/src/pages/api/docs/[slug].md.ts @@ -1,27 +1,11 @@ import type { APIRoute } from 'astro'; import { getEntry } from 'astro:content'; import { sha256Hex } from '../../../lib/contentHash'; -import { htmlToText } from '../../../lib/htmlToText'; +import { DOCS_CACHE_CONTROL, SAFE_SLUG, ifNoneMatchMatches, quoteEtag } from '../../../lib/docsApi'; +import { mdxToText } from '../../../lib/mdxToText'; export const prerender = false; -const SAFE_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/; - -function extractArticle(html: string): string { - const start = html.indexOf(''); - const end = html.indexOf(''); - if (start === -1 || end === -1 || end <= start) return ''; - return html.slice(start + ''.length, end); -} - -async function renderEntryText(slug: string, origin: string, fetcher: typeof fetch): Promise { - const res = await fetcher(`${origin}/docs/${slug}`); - if (!res.ok) throw new Error(`Failed to render /docs/${slug}: ${res.status}`); - const html = await res.text(); - const article = extractArticle(html); - return htmlToText(article); -} - export const GET: APIRoute = async ({ params, request }) => { const slug = params.slug; @@ -34,29 +18,28 @@ export const GET: APIRoute = async ({ params, request }) => { return new Response('Not found', { status: 404 }); } - const url = new URL(request.url); - const body = await renderEntryText(slug, url.origin, fetch); + const body = await mdxToText(entry.body ?? ''); const contentHash = `sha256:${await sha256Hex(body)}`; + const etag = quoteEtag(contentHash); + + const ifNoneMatch = request.headers.get('if-none-match'); + if (ifNoneMatchMatches(ifNoneMatch, etag)) { + return new Response(null, { + status: 304, + headers: { + 'Cache-Control': DOCS_CACHE_CONTROL, + 'ETag': etag, + 'X-Content-Hash': contentHash, + }, + }); + } - const frontmatter = [ - '---', - `title: ${JSON.stringify(entry.data.title)}`, - `description: ${JSON.stringify(entry.data.description)}`, - `order: ${entry.data.order}`, - `slug: ${entry.id}`, - '---', - '', - ].join('\n'); - - const responseBody = `${frontmatter}\n${body}`; - const etag = `sha256:${await sha256Hex(responseBody)}`; - - return new Response(responseBody, { + return new Response(body, { status: 200, headers: { 'Content-Type': 'text/markdown; charset=utf-8', - 'Cache-Control': 'public, max-age=60, s-maxage=300', - 'ETag': `"${etag}"`, + 'Cache-Control': DOCS_CACHE_CONTROL, + 'ETag': etag, 'X-Content-Hash': contentHash, }, }); diff --git a/src/pages/api/docs/manifest.json.ts b/src/pages/api/docs/manifest.json.ts index b14bbea..9a3593d 100644 --- a/src/pages/api/docs/manifest.json.ts +++ b/src/pages/api/docs/manifest.json.ts @@ -1,53 +1,60 @@ import type { APIRoute } from 'astro'; import { getCollection } from 'astro:content'; import { sha256Hex } from '../../../lib/contentHash'; -import { htmlToText } from '../../../lib/htmlToText'; +import { DOCS_CACHE_CONTROL, SAFE_SLUG, ifNoneMatchMatches, quoteEtag } from '../../../lib/docsApi'; +import { mdxToText } from '../../../lib/mdxToText'; export const prerender = false; -function extractArticle(html: string): string { - const start = html.indexOf(''); - const end = html.indexOf(''); - if (start === -1 || end === -1 || end <= start) return ''; - return html.slice(start + ''.length, end); -} - export const GET: APIRoute = async ({ request }) => { - const entries = await getCollection('docs'); - entries.sort((a, b) => a.data.order - b.data.order); - - const origin = new URL(request.url).origin; + const entries = (await getCollection('docs')).filter((e) => SAFE_SLUG.test(e.id)); + entries.sort((a, b) => { + if (a.data.order !== b.data.order) return a.data.order - b.data.order; + return a.id.localeCompare(b.id); + }); const manifestEntries = await Promise.all( entries.map(async (entry) => { - const res = await fetch(`${origin}/docs/${entry.id}`); - const html = res.ok ? await res.text() : ''; - const text = htmlToText(extractArticle(html)); + const text = await mdxToText(entry.body ?? ''); + const contentHash = `sha256:${await sha256Hex(text)}`; return { slug: entry.id, title: entry.data.title, description: entry.data.description, order: entry.data.order, - contentHash: `sha256:${await sha256Hex(text)}`, + contentHash, contentUrl: `/api/docs/${entry.id}.md`, }; }), ); - const version = `sha256:${await sha256Hex(JSON.stringify(manifestEntries))}`; + const versionSeed = JSON.stringify(manifestEntries.map((e) => [e.slug, e.contentHash])); + const version = `sha256:${await sha256Hex(versionSeed)}`; + const etag = quoteEtag(version); + + const ifNoneMatch = request.headers.get('if-none-match'); + if (ifNoneMatchMatches(ifNoneMatch, etag)) { + return new Response(null, { + status: 304, + headers: { + 'Cache-Control': DOCS_CACHE_CONTROL, + 'ETag': etag, + }, + }); + } + const payload = { version, entries: manifestEntries, }; const body = JSON.stringify(payload, null, 2); - const etag = `sha256:${await sha256Hex(body)}`; return new Response(body, { status: 200, headers: { 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': 'public, max-age=60, s-maxage=300', - 'ETag': `"${etag}"`, + 'Cache-Control': DOCS_CACHE_CONTROL, + 'ETag': etag, }, }); }; diff --git a/src/tests/api/docs.test.ts b/src/tests/api/docs.test.ts new file mode 100644 index 0000000..dc56bee --- /dev/null +++ b/src/tests/api/docs.test.ts @@ -0,0 +1,123 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { startDocsApiServer, type DocsApiServer } from '../helpers/docsApiServer'; + +let server: DocsApiServer; + +beforeAll(async () => { + server = await startDocsApiServer(); +}, 60_000); + +afterAll(async () => { + if (server) await server.stop(); +}); + +interface ManifestEntry { + slug: string; + title: string; + description: string; + order: number; + contentHash: string; + contentUrl: string; +} + +interface Manifest { + version: string; + entries: ManifestEntry[]; +} + +describe('GET /api/docs/manifest.json', () => { + it('returns 200 with a valid manifest payload', async () => { + const res = await server.fetch('/api/docs/manifest.json'); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toMatch(/application\/json/i); + expect(res.headers.get('cache-control')).toBe('public, max-age=900, s-maxage=900'); + const payload = (await res.json()) as Manifest; + expect(payload.version).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(payload.entries.length).toBeGreaterThan(0); + for (const entry of payload.entries) { + expect(entry.slug).toMatch(/^[a-z0-9][a-z0-9-]{0,63}$/); + expect(entry.contentHash).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(entry.contentUrl).toBe(`/api/docs/${entry.slug}.md`); + } + }); + + it('is ordered by (order, slug)', async () => { + const res = await server.fetch('/api/docs/manifest.json'); + const payload = (await res.json()) as Manifest; + const expected = [...payload.entries] + .map((e) => ({ slug: e.slug, order: e.order })) + .sort((a, b) => (a.order !== b.order ? a.order - b.order : a.slug.localeCompare(b.slug))); + expect(payload.entries.map((e) => e.slug)).toEqual(expected.map((e) => e.slug)); + }); + + it('has a stable version and ETag across hits', async () => { + const a = await server.fetch('/api/docs/manifest.json'); + const b = await server.fetch('/api/docs/manifest.json'); + const versionA = ((await a.json()) as Manifest).version; + const versionB = ((await b.json()) as Manifest).version; + expect(versionA).toBe(versionB); + expect(a.headers.get('etag')).toBe(b.headers.get('etag')); + }); + + it('returns 304 on matching If-None-Match', async () => { + const first = await server.fetch('/api/docs/manifest.json'); + const etag = first.headers.get('etag')!; + expect(etag).toBeTruthy(); + const second = await server.fetch('/api/docs/manifest.json', { + headers: { 'If-None-Match': etag }, + }); + expect(second.status).toBe(304); + expect(second.headers.get('etag')).toBe(etag); + }); + + it('matches per-entry contentHash with the body endpoint hash', async () => { + const res = await server.fetch('/api/docs/manifest.json'); + const payload = (await res.json()) as Manifest; + const entry = payload.entries[0]; + const bodyRes = await server.fetch(entry.contentUrl); + expect(bodyRes.status).toBe(200); + expect(bodyRes.headers.get('x-content-hash')).toBe(entry.contentHash); + }); +}); + +describe('GET /api/docs/[slug].md', () => { + it('returns 400 for an invalid slug', async () => { + const res = await server.fetch('/api/docs/Bad-Slug.md'); + expect(res.status).toBe(400); + }); + + it('returns 404 for an unknown slug', async () => { + const res = await server.fetch('/api/docs/nonexistent.md'); + expect(res.status).toBe(404); + }); + + it('returns 200 with markdown content for a valid slug', async () => { + const res = await server.fetch('/api/docs/intro.md'); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toMatch(/text\/markdown/i); + expect(res.headers.get('cache-control')).toBe('public, max-age=900, s-maxage=900'); + const body = await res.text(); + expect(body).not.toMatch(/^---\s*\n/); + expect(body).not.toMatch(/^import\s/m); + expect(body).not.toMatch(/^export\s/m); + expect(body.replace(/```[\s\S]*?```/g, '')).not.toMatch(/<[a-zA-Z]/); + }); + + it('coheres ETag with X-Content-Hash', async () => { + const res = await server.fetch('/api/docs/intro.md'); + const etag = res.headers.get('etag')!; + const hash = res.headers.get('x-content-hash')!; + expect(etag).toBe(`"${hash}"`); + }); + + it('returns 304 on matching If-None-Match', async () => { + const first = await server.fetch('/api/docs/intro.md'); + const etag = first.headers.get('etag')!; + expect(etag).toBeTruthy(); + const second = await server.fetch('/api/docs/intro.md', { + headers: { 'If-None-Match': etag }, + }); + expect(second.status).toBe(304); + expect(second.headers.get('etag')).toBe(etag); + }); +}); diff --git a/src/tests/helpers/docsApiServer.ts b/src/tests/helpers/docsApiServer.ts new file mode 100644 index 0000000..479775a --- /dev/null +++ b/src/tests/helpers/docsApiServer.ts @@ -0,0 +1,29 @@ +import { dev, type AstroInlineConfig } from 'astro'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +type DevServer = Awaited>; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +export interface DocsApiServer { + baseUrl: string; + fetch: (path: string, init?: RequestInit) => Promise; + stop: () => Promise; +} + +export async function startDocsApiServer(): Promise { + const config: AstroInlineConfig = { + root: projectRoot, + logLevel: 'error', + server: { host: '127.0.0.1', port: 0 }, + }; + const server: DevServer = await dev(config); + const address = server.address; + const baseUrl = `http://127.0.0.1:${address.port}`; + return { + baseUrl, + fetch: (p: string, init?: RequestInit) => fetch(`${baseUrl}${p}`, init), + stop: () => server.stop(), + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 7dd1325..4452073 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,6 @@ export default defineConfig({ globals: true, environment: 'node', include: ['src/**/*.test.ts'], + fileParallelism: false, }, }); diff --git a/wrangler.jsonc b/wrangler.jsonc index 3a09d62..24c3381 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,6 +1,6 @@ { "compatibility_date": "2026-03-17", - "compatibility_flags": ["global_fetch_strictly_public"], + "compatibility_flags": ["global_fetch_strictly_public", "nodejs_compat"], "name": "nan-website", "main": "@astrojs/cloudflare/entrypoints/server", "assets": { From 8f7c344bb377d43c83042ca5405016adf8ebe6ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20G=C3=B3mez=20Jim=C3=A9nez?= Date: Tue, 26 May 2026 19:35:06 +0200 Subject: [PATCH 05/11] fix(ci): satisfy astro check under strict mode - mdxToText: replace TS2352 casts with index-signature access - tsconfig: exclude tests so astro check no longer needs node:* types Co-Authored-By: Claude Opus 4.7 --- src/lib/mdxToText.ts | 20 +++++++++++--------- tsconfig.json | 4 +++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/lib/mdxToText.ts b/src/lib/mdxToText.ts index bca6950..3637994 100644 --- a/src/lib/mdxToText.ts +++ b/src/lib/mdxToText.ts @@ -66,15 +66,17 @@ export function htmlNodeToMarkdown(input: string): string { export function astToValue(node: unknown): unknown { if (!node || typeof node !== 'object') return undefined; - const n = node as { type: string; [k: string]: unknown }; + const n = node as { [k: string]: unknown }; switch (n.type) { case 'Literal': - return (n as { value: unknown }).value; - case 'ArrayExpression': - return ((n as { elements: unknown[] }).elements || []).map((e) => astToValue(e)); + return n.value; + case 'ArrayExpression': { + const elements = (n.elements as unknown[] | undefined) ?? []; + return elements.map((e) => astToValue(e)); + } case 'ObjectExpression': { const obj: Record = {}; - const props = (n as { properties: unknown[] }).properties || []; + const props = (n.properties as unknown[] | undefined) ?? []; for (const p of props) { const prop = p as { type: string; key: { type: string; name?: string; value?: unknown }; value: unknown }; if (prop.type !== 'Property') continue; @@ -86,19 +88,19 @@ export function astToValue(node: unknown): unknown { return obj; } case 'TemplateLiteral': { - const quasis = ((n as { quasis: { value: { cooked: string } }[] }).quasis || []); + const quasis = (n.quasis as { value: { cooked: string } }[] | undefined) ?? []; return quasis.map((q) => q.value.cooked).join(''); } case 'UnaryExpression': { - const op = (n as { operator: string; argument: unknown }).operator; - const arg = astToValue((n as { argument: unknown }).argument); + const op = n.operator as string; + const arg = astToValue(n.argument); if (op === '-' && typeof arg === 'number') return -arg; if (op === '+' && typeof arg === 'number') return arg; if (op === '!') return !arg; return undefined; } case 'Identifier': { - const name = (n as { name: string }).name; + const name = n.name as string; if (name === 'undefined') return undefined; return undefined; } diff --git a/tsconfig.json b/tsconfig.json index a250963..6368186 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,9 @@ "./worker-configuration.d.ts" ], "exclude": [ - "dist" + "dist", + "src/**/*.test.ts", + "src/tests" ], "compilerOptions": { "jsx": "react-jsx", From 5fd334eecfab5de25d2969ba25a60e8673401373 Mon Sep 17 00:00:00 2001 From: Santi <26361103+santigamo@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:56:18 +0200 Subject: [PATCH 06/11] fix(docs): fail-fast on MDX expressions the text extractor cannot evaluate transformTree dropped mdxjsEsm, mdxFlowExpression and mdxTextExpression alike. Only the first is safe to drop: import/export is how .mdx pulls in its components, and src/pages/docs/[...slug].astro renders without a `components` prop, so throwing there would break every doc. The gap that actually mattered was elsewhere. transformTree never walks .attributes, but componentToBlockMd() consumes them through getAttr(), and astToValue() returned undefined for anything it could not reduce. A non-literal prop therefore made the whole component vanish from the canonical text while the page kept rendering it: -> "- []() - `GET /v1/models`" -> "" Now astToValue throws on identifiers, spreads, interpolated template literals and any other non-literal node, getAttr rejects spread attributes, and bare {expr} at block or inline level throws. import and export keep being dropped, which mdxToText.test.ts already pinned. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/mdxToText.test.ts | 33 +++++++++++++++++++++++++++ src/lib/mdxToText.ts | 47 ++++++++++++++++++++++++++++++++------- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/lib/mdxToText.test.ts b/src/lib/mdxToText.test.ts index 6167c82..fc946cf 100644 --- a/src/lib/mdxToText.test.ts +++ b/src/lib/mdxToText.test.ts @@ -82,4 +82,37 @@ describe('mdxToText error cases', () => { const input = '\n'; await expect(mdxToText(input)).rejects.toThrow(/UnknownThing/); }); + + it('throws for a bare MDX flow expression', async () => { + await expect(mdxToText('texto\n\n{someVar}\n')).rejects.toThrow(/Unexpected MDX expression: \{someVar\}/); + }); + + it('throws for a bare MDX text expression', async () => { + await expect(mdxToText('hola {inline} mundo\n')).rejects.toThrow(/Unexpected MDX expression: \{inline\}/); + }); + + it('ignores import/export, which is how .mdx pulls in its components', async () => { + const input = "import Foo from './Foo.astro';\n\ntexto\n"; + await expect(mdxToText(input)).resolves.toBe('texto'); + }); + + it('throws when a prop expression does not reduce to a literal', async () => { + const input = "import EndpointGrid from './x';\n\n\n"; + await expect(mdxToText(input)).rejects.toThrow(/identifier "items" \(in prop "items"\)/); + }); + + it('throws for a template literal prop with interpolation', async () => { + const input = "import Callout from './x';\n\n\n\ntexto\n\n\n"; + await expect(mdxToText(input)).rejects.toThrow(/template literal with interpolation/); + }); + + it('throws for a spread attribute', async () => { + const input = "import Callout from './x';\n\n\n\ntexto\n\n\n"; + await expect(mdxToText(input)).rejects.toThrow(/spread attribute/); + }); + + it('keeps rendering components whose props are literals', async () => { + const input = "import EndpointGrid from './x';\n\n\n"; + await expect(mdxToText(input)).resolves.toContain('GET /v1/models'); + }); }); diff --git a/src/lib/mdxToText.ts b/src/lib/mdxToText.ts index 3637994..1bee09a 100644 --- a/src/lib/mdxToText.ts +++ b/src/lib/mdxToText.ts @@ -64,8 +64,17 @@ export function htmlNodeToMarkdown(input: string): string { return stripInlineHtml(s); } +/** + * Reduces a prop expression to a static value. + * + * Only literal constructs are supported: anything the canonical text extractor + * cannot evaluate throws, because the alternative is a component silently + * vanishing from the text served to API consumers while the page still renders. + */ export function astToValue(node: unknown): unknown { - if (!node || typeof node !== 'object') return undefined; + if (!node || typeof node !== 'object') { + throw new Error('Unsupported MDX expression: missing node'); + } const n = node as { [k: string]: unknown }; switch (n.type) { case 'Literal': @@ -79,7 +88,9 @@ export function astToValue(node: unknown): unknown { const props = (n.properties as unknown[] | undefined) ?? []; for (const p of props) { const prop = p as { type: string; key: { type: string; name?: string; value?: unknown }; value: unknown }; - if (prop.type !== 'Property') continue; + if (prop.type !== 'Property') { + throw new Error(`Unsupported MDX expression: ${prop.type} in object literal`); + } let key: string; if (prop.key.type === 'Identifier') key = prop.key.name as string; else key = String(astToValue(prop.key)); @@ -88,6 +99,10 @@ export function astToValue(node: unknown): unknown { return obj; } case 'TemplateLiteral': { + const expressions = (n.expressions as unknown[] | undefined) ?? []; + if (expressions.length > 0) { + throw new Error('Unsupported MDX expression: template literal with interpolation'); + } const quasis = (n.quasis as { value: { cooked: string } }[] | undefined) ?? []; return quasis.map((q) => q.value.cooked).join(''); } @@ -97,15 +112,15 @@ export function astToValue(node: unknown): unknown { if (op === '-' && typeof arg === 'number') return -arg; if (op === '+' && typeof arg === 'number') return arg; if (op === '!') return !arg; - return undefined; + throw new Error(`Unsupported MDX expression: unary operator "${op}"`); } case 'Identifier': { const name = n.name as string; if (name === 'undefined') return undefined; - return undefined; + throw new Error(`Unsupported MDX expression: identifier "${name}"`); } default: - return undefined; + throw new Error(`Unsupported MDX expression: ${String(n.type)}`); } } @@ -117,6 +132,10 @@ export function getAttr(node: { attributes?: unknown[] }, name: string): unknown name?: string; value?: unknown; }; + // A spread hides which props a component actually receives. + if (attr.type === 'mdxJsxExpressionAttribute') { + throw new Error('Unsupported MDX expression: spread attribute'); + } if (attr.type !== 'mdxJsxAttribute') continue; if (attr.name !== name) continue; if (typeof attr.value === 'string') return attr.value; @@ -124,11 +143,17 @@ export function getAttr(node: { attributes?: unknown[] }, name: string): unknown const v = attr.value as { type: string; data?: { estree?: { body?: unknown[] } } }; if (v.type === 'mdxJsxAttributeValueExpression') { const body = v.data?.estree?.body; - if (Array.isArray(body) && body[0]) { - const expr = (body[0] as { expression?: unknown }).expression; + if (!Array.isArray(body) || !body[0]) { + throw new Error(`Unsupported MDX expression: empty expression (in prop "${name}")`); + } + const expr = (body[0] as { expression?: unknown }).expression; + try { return astToValue(expr); + } catch (err) { + throw new Error(`${(err as Error).message} (in prop "${name}")`); } } + throw new Error(`Unsupported MDX expression: ${v.type} (in prop "${name}")`); } return undefined; } @@ -395,10 +420,16 @@ function transformTree(root: { children?: unknown[] }): void { child.children = newChildren; } - if (isMdxEsm(child.type) || isMdxExpression(child.type)) { + // import/export never render; they are how .mdx pulls in its components. + if (isMdxEsm(child.type)) { continue; } + if (isMdxExpression(child.type)) { + const raw = ((child as unknown as { value?: string }).value ?? '').trim(); + throw new Error(`Unexpected MDX expression: {${raw}}`); + } + if (child.type === 'html') { const md = htmlNodeToMarkdown((child as unknown as { value: string }).value); out.push(...mdToBlockChildren(md)); From 5dbafec275ee2889933022e0912165953f4ec9f2 Mon Sep 17 00:00:00 2001 From: Santi <26361103+santigamo@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:01:45 +0200 Subject: [PATCH 07/11] feat(docs): single source of truth for rate limits The numbers lived in three places and had already drifted. After merging main, rendered 60 rpm while rateLimitsToMd() served 100 rpm to the Discord bot, and the extractor knew nothing about the per-model tpm and rerank tables the component shows. src/lib/rateLimits.ts now owns the data. The component and the docs API both read it, so the page and the bot cannot disagree. The two per-key limits come from PUBLIC_RATE_LIMIT_RPM and PUBLIC_RATE_LIMIT_PARALLEL, read through `cloudflare:workers` like every other env var here; an invalid value warns and falls back rather than taking the docs down. The per-model tables stay as typed data: they only change when a model is added or removed, which is a code change anyway. Defaults are 60 rpm and 5 concurrentes, per a37bb63, which corrected the 3 concurrentes the review had assumed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 7 ++ src/components/docs/RateLimits.astro | 44 ++++++------ src/env.d.ts | 3 + src/lib/__fixtures__/ratelimits.expected.md | 17 ++++- src/lib/mdxToText.test.ts | 32 +++++++++ src/lib/mdxToText.ts | 37 +++++++--- src/lib/rateLimits.test.ts | 47 +++++++++++++ src/lib/rateLimits.ts | 76 +++++++++++++++++++++ src/pages/api/docs/[slug].md.ts | 4 +- src/pages/api/docs/manifest.json.ts | 5 +- 10 files changed, 238 insertions(+), 34 deletions(-) create mode 100644 src/lib/rateLimits.test.ts create mode 100644 src/lib/rateLimits.ts diff --git a/.env.example b/.env.example index 0913942..c6d935b 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,10 @@ CLOUD_API_WAITLIST_KEY= # is missing, /api/waitlist still succeeds but skips the confirmation email. RESEND_API_KEY= RESEND_FROM_EMAIL=no-reply@nan.builders + +# Per-key rate limits shown in the docs and served to the Discord bot. +# Not secrets. Optional — omit to use the defaults in src/lib/rateLimits.ts +# (60 rpm, 5 concurrentes). Changing them changes the docs contentHash, so +# the bot re-syncs on its next manifest poll. +PUBLIC_RATE_LIMIT_RPM=60 +PUBLIC_RATE_LIMIT_PARALLEL=5 diff --git a/src/components/docs/RateLimits.astro b/src/components/docs/RateLimits.astro index 99113d7..0695057 100644 --- a/src/components/docs/RateLimits.astro +++ b/src/components/docs/RateLimits.astro @@ -1,4 +1,8 @@ --- +import { env } from 'cloudflare:workers'; +import { getRateLimitsConfig } from '../../lib/rateLimits'; + +const { perKey, tokensPerMinuteByModel, requestsPerMinuteByModel } = getRateLimitsConfig(env); ---
@@ -8,11 +12,11 @@
Requests / min
-
60 rpm
+
{perKey.requestsPerMinute} rpm
Paralelo máximo
-
5 concurrentes
+
{perKey.maxParallel} concurrentes
@@ -22,22 +26,14 @@ tokens / min por modelo

-
-
deepseek-v4-flash
-
1.5M tpm
-
-
-
mimo-v2.5
-
1.5M tpm
-
-
-
qwen3.6
-
1.5M tpm
-
-
-
gemma4
-
1.5M tpm
-
+ { + tokensPerMinuteByModel.map((m) => ( +
+
{m.model}
+
{m.label}
+
+ )) + }
@@ -46,9 +42,13 @@ requests / min por modelo

-
-
rerank
-
1000 rpm
-
+ { + requestsPerMinuteByModel.map((m) => ( +
+
{m.model}
+
{m.label}
+
+ )) + }
diff --git a/src/env.d.ts b/src/env.d.ts index 330d2a5..758c875 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -10,6 +10,9 @@ declare namespace Cloudflare { RESEND_FROM_EMAIL: string; CLOUD_API_URL: string; CLOUD_API_WAITLIST_KEY: string; + // Optional: both fall back to the defaults in src/lib/rateLimits.ts. + PUBLIC_RATE_LIMIT_RPM?: string; + PUBLIC_RATE_LIMIT_PARALLEL?: string; } } diff --git a/src/lib/__fixtures__/ratelimits.expected.md b/src/lib/__fixtures__/ratelimits.expected.md index bd769c6..46febe9 100644 --- a/src/lib/__fixtures__/ratelimits.expected.md +++ b/src/lib/__fixtures__/ratelimits.expected.md @@ -1,2 +1,15 @@ -- Requests / min: 100 rpm -- Paralelo máximo: 5 concurrentes \ No newline at end of file +**rate limits por API key** + +- Requests / min: 60 rpm +- Paralelo máximo: 5 concurrentes + +**tokens / min por modelo** + +- deepseek-v4-flash: 1.5M tpm +- mimo-v2.5: 1.5M tpm +- qwen3.6: 1.5M tpm +- gemma4: 1.5M tpm + +**requests / min por modelo** + +- rerank: 1000 rpm \ No newline at end of file diff --git a/src/lib/mdxToText.test.ts b/src/lib/mdxToText.test.ts index fc946cf..a24193b 100644 --- a/src/lib/mdxToText.test.ts +++ b/src/lib/mdxToText.test.ts @@ -77,6 +77,38 @@ describe('mdxToText invariants on docs corpus', () => { }); }); +describe('mdxToText rate limits', () => { + const input = "import RateLimits from '../../components/docs/RateLimits.astro';\n\n\n"; + + it('serves the values from the injected config, not hardcoded ones', async () => { + const out = await mdxToText(input, { + perKey: { requestsPerMinute: 120, maxParallel: 8 }, + tokensPerMinuteByModel: [{ model: 'foo', label: '2M tpm' }], + requestsPerMinuteByModel: [{ model: 'bar', label: '500 rpm' }], + }); + expect(out).toContain('- Requests / min: 120 rpm'); + expect(out).toContain('- Paralelo máximo: 8 concurrentes'); + expect(out).toContain('- foo: 2M tpm'); + expect(out).toContain('- bar: 500 rpm'); + }); + + it('defaults to the same numbers renders', async () => { + const out = await mdxToText(input); + expect(out).toContain('- Requests / min: 60 rpm'); + expect(out).toContain('- Paralelo máximo: 5 concurrentes'); + }); + + it('omits the per-model blocks when they are empty', async () => { + const out = await mdxToText(input, { + perKey: { requestsPerMinute: 60, maxParallel: 5 }, + tokensPerMinuteByModel: [], + requestsPerMinuteByModel: [], + }); + expect(out).not.toContain('tokens / min por modelo'); + expect(out).not.toContain('requests / min por modelo'); + }); +}); + describe('mdxToText error cases', () => { it('throws for an unknown MDX component', async () => { const input = '\n'; diff --git a/src/lib/mdxToText.ts b/src/lib/mdxToText.ts index 1bee09a..a997a4d 100644 --- a/src/lib/mdxToText.ts +++ b/src/lib/mdxToText.ts @@ -3,6 +3,7 @@ import remarkParse from 'remark-parse'; import remarkGfm from 'remark-gfm'; import remarkMdx from 'remark-mdx'; import remarkStringify from 'remark-stringify'; +import { DEFAULT_RATE_LIMITS, type RateLimitsConfig } from './rateLimits'; const KNOWN_COMPONENTS = new Set([ 'ModelCard', @@ -221,7 +222,7 @@ interface MdxNode { children?: unknown[]; } -function componentToBlockMd(node: MdxNode): string { +function componentToBlockMd(node: MdxNode, rateLimits: RateLimitsConfig): string { const name = getName(node); switch (name) { case 'ModelCard': @@ -235,7 +236,7 @@ function componentToBlockMd(node: MdxNode): string { case 'Callout': return calloutToMd(node); case 'RateLimits': - return rateLimitsToMd(); + return rateLimitsToMd(rateLimits); default: throw new Error(`Unknown MDX block component: <${name || '?'}>`); } @@ -327,8 +328,25 @@ function calloutToMd(node: MdxNode): string { return `> [!${variant}] ${title}\n>\n${quoted}\n`; } -function rateLimitsToMd(): string { - return ['- Requests / min: 100 rpm', '- Paralelo máximo: 5 concurrentes', ''].join('\n'); +function rateLimitsToMd(config: RateLimitsConfig): string { + const lines = [ + '**rate limits por API key**', + '', + `- Requests / min: ${config.perKey.requestsPerMinute} rpm`, + `- Paralelo máximo: ${config.perKey.maxParallel} concurrentes`, + '', + ]; + if (config.tokensPerMinuteByModel.length) { + lines.push('**tokens / min por modelo**', ''); + for (const m of config.tokensPerMinuteByModel) lines.push(`- ${m.model}: ${m.label}`); + lines.push(''); + } + if (config.requestsPerMinuteByModel.length) { + lines.push('**requests / min por modelo**', ''); + for (const m of config.requestsPerMinuteByModel) lines.push(`- ${m.model}: ${m.label}`); + lines.push(''); + } + return lines.join('\n'); } function htmlTagToBlockMd(node: MdxNode): string { @@ -406,7 +424,7 @@ function promoteHeadingParagraphs(root: { children?: unknown[] }): void { root.children = out; } -function transformTree(root: { children?: unknown[] }): void { +function transformTree(root: { children?: unknown[] }, rateLimits: RateLimitsConfig): void { promoteHeadingParagraphs(root); function process(node: { type?: string; children?: unknown[] }): unknown[] { @@ -439,7 +457,7 @@ function transformTree(root: { children?: unknown[] }): void { if (isMdxJsxFlowComponent(child.type)) { const name = getName(child); if (KNOWN_COMPONENTS.has(name)) { - const md = componentToBlockMd(child); + const md = componentToBlockMd(child, rateLimits); out.push(...mdToBlockChildren(md)); } else if (AUTHOR_HTML_BLOCK_TAGS.has(name.toLowerCase()) || AUTHOR_HTML_INLINE_TAGS.has(name.toLowerCase())) { const md = htmlTagToBlockMd(child); @@ -477,10 +495,13 @@ export function normalizeCanonicalText(text: string): string { return s.trim(); } -export async function mdxToText(body: string): Promise { +export async function mdxToText( + body: string, + rateLimits: RateLimitsConfig = DEFAULT_RATE_LIMITS, +): Promise { const parser = unified().use(remarkParse).use(remarkGfm).use(remarkMdx); const tree = parser.parse(body) as { children: unknown[] }; - transformTree(tree as { children?: unknown[] }); + transformTree(tree as { children?: unknown[] }, rateLimits); const out = stringifier.stringify(tree as never) as string; return normalizeCanonicalText(out); } diff --git a/src/lib/rateLimits.test.ts b/src/lib/rateLimits.test.ts new file mode 100644 index 0000000..7cb1e38 --- /dev/null +++ b/src/lib/rateLimits.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_RATE_LIMITS, getRateLimitsConfig } from './rateLimits'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('getRateLimitsConfig', () => { + it('falls back to the defaults when the vars are absent', () => { + expect(getRateLimitsConfig({})).toEqual(DEFAULT_RATE_LIMITS); + }); + + it('reads both per-key limits from the env', () => { + const config = getRateLimitsConfig({ + PUBLIC_RATE_LIMIT_RPM: '120', + PUBLIC_RATE_LIMIT_PARALLEL: '8', + }); + expect(config.perKey).toEqual({ requestsPerMinute: 120, maxParallel: 8 }); + }); + + it('leaves the per-model tables untouched', () => { + const config = getRateLimitsConfig({ PUBLIC_RATE_LIMIT_RPM: '120' }); + expect(config.tokensPerMinuteByModel).toEqual(DEFAULT_RATE_LIMITS.tokensPerMinuteByModel); + expect(config.requestsPerMinuteByModel).toEqual(DEFAULT_RATE_LIMITS.requestsPerMinuteByModel); + }); + + it.each(['0', '-1', 'abc', '1.5', '60rpm'])( + 'ignores the invalid value %s and warns instead of taking the docs down', + (raw) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = getRateLimitsConfig({ PUBLIC_RATE_LIMIT_RPM: raw }); + expect(config.perKey.requestsPerMinute).toBe(DEFAULT_RATE_LIMITS.perKey.requestsPerMinute); + expect(warn).toHaveBeenCalledOnce(); + }, + ); + + it('treats an empty string as absent, without warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = getRateLimitsConfig({ PUBLIC_RATE_LIMIT_PARALLEL: ' ' }); + expect(config.perKey.maxParallel).toBe(DEFAULT_RATE_LIMITS.perKey.maxParallel); + expect(warn).not.toHaveBeenCalled(); + }); + + it('defaults to the values main settled on: 60 rpm, 5 concurrentes', () => { + expect(DEFAULT_RATE_LIMITS.perKey).toEqual({ requestsPerMinute: 60, maxParallel: 5 }); + }); +}); diff --git a/src/lib/rateLimits.ts b/src/lib/rateLimits.ts new file mode 100644 index 0000000..527fe1c --- /dev/null +++ b/src/lib/rateLimits.ts @@ -0,0 +1,76 @@ +/** + * Single source of truth for the rate limits shown in the docs. + * + * Consumed by (what humans read) and by rateLimitsToMd() + * (what /api/docs serves to the Discord bot). Keeping one module means the + * page and the API cannot disagree, which they did: the component said + * 60 rpm while the extractor hardcoded 100 rpm. + * + * Receives the env as a parameter so it can be unit-tested without runtime + * bindings, mirroring src/lib/email.ts. + */ + +export interface PerKeyRateLimits { + requestsPerMinute: number; + maxParallel: number; +} + +export interface ModelRate { + model: string; + label: string; +} + +export interface RateLimitsConfig { + perKey: PerKeyRateLimits; + tokensPerMinuteByModel: ModelRate[]; + requestsPerMinuteByModel: ModelRate[]; +} + +export interface RateLimitsEnv { + PUBLIC_RATE_LIMIT_RPM?: string; + PUBLIC_RATE_LIMIT_PARALLEL?: string; +} + +/** + * Per-model tables stay here rather than in env vars: they only change when a + * model is added or removed, which is a code change anyway. + */ +export const DEFAULT_RATE_LIMITS: RateLimitsConfig = { + perKey: { requestsPerMinute: 60, maxParallel: 5 }, + tokensPerMinuteByModel: [ + { model: 'deepseek-v4-flash', label: '1.5M tpm' }, + { model: 'mimo-v2.5', label: '1.5M tpm' }, + { model: 'qwen3.6', label: '1.5M tpm' }, + { model: 'gemma4', label: '1.5M tpm' }, + ], + requestsPerMinuteByModel: [{ model: 'rerank', label: '1000 rpm' }], +}; + +function parsePositiveInt(raw: string | undefined, fallback: number, varName: string): number { + if (raw === undefined || raw.trim() === '') return fallback; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + // A misconfigured var must not take the docs down, but it must be loud. + console.warn(`[rateLimits] ignoring invalid ${varName}=${JSON.stringify(raw)}, using ${fallback}`); + return fallback; + } + return n; +} + +export function getRateLimitsConfig(env: RateLimitsEnv = {}): RateLimitsConfig { + return { + ...DEFAULT_RATE_LIMITS, + perKey: { + requestsPerMinute: parsePositiveInt( + env.PUBLIC_RATE_LIMIT_RPM, + DEFAULT_RATE_LIMITS.perKey.requestsPerMinute, + 'PUBLIC_RATE_LIMIT_RPM', + ), + maxParallel: parsePositiveInt( + env.PUBLIC_RATE_LIMIT_PARALLEL, + DEFAULT_RATE_LIMITS.perKey.maxParallel, + 'PUBLIC_RATE_LIMIT_PARALLEL', + ), + }, + }; +} diff --git a/src/pages/api/docs/[slug].md.ts b/src/pages/api/docs/[slug].md.ts index c210834..e826563 100644 --- a/src/pages/api/docs/[slug].md.ts +++ b/src/pages/api/docs/[slug].md.ts @@ -1,8 +1,10 @@ import type { APIRoute } from 'astro'; import { getEntry } from 'astro:content'; +import { env } from 'cloudflare:workers'; import { sha256Hex } from '../../../lib/contentHash'; import { DOCS_CACHE_CONTROL, SAFE_SLUG, ifNoneMatchMatches, quoteEtag } from '../../../lib/docsApi'; import { mdxToText } from '../../../lib/mdxToText'; +import { getRateLimitsConfig } from '../../../lib/rateLimits'; export const prerender = false; @@ -18,7 +20,7 @@ export const GET: APIRoute = async ({ params, request }) => { return new Response('Not found', { status: 404 }); } - const body = await mdxToText(entry.body ?? ''); + const body = await mdxToText(entry.body ?? '', getRateLimitsConfig(env)); const contentHash = `sha256:${await sha256Hex(body)}`; const etag = quoteEtag(contentHash); diff --git a/src/pages/api/docs/manifest.json.ts b/src/pages/api/docs/manifest.json.ts index 9a3593d..1446e10 100644 --- a/src/pages/api/docs/manifest.json.ts +++ b/src/pages/api/docs/manifest.json.ts @@ -1,8 +1,10 @@ import type { APIRoute } from 'astro'; import { getCollection } from 'astro:content'; +import { env } from 'cloudflare:workers'; import { sha256Hex } from '../../../lib/contentHash'; import { DOCS_CACHE_CONTROL, SAFE_SLUG, ifNoneMatchMatches, quoteEtag } from '../../../lib/docsApi'; import { mdxToText } from '../../../lib/mdxToText'; +import { getRateLimitsConfig } from '../../../lib/rateLimits'; export const prerender = false; @@ -13,9 +15,10 @@ export const GET: APIRoute = async ({ request }) => { return a.id.localeCompare(b.id); }); + const rateLimits = getRateLimitsConfig(env); const manifestEntries = await Promise.all( entries.map(async (entry) => { - const text = await mdxToText(entry.body ?? ''); + const text = await mdxToText(entry.body ?? '', rateLimits); const contentHash = `sha256:${await sha256Hex(text)}`; return { slug: entry.id, From 8ce89a8af296943cc8a09fb595aec1f20c1206b6 Mon Sep 17 00:00:00 2001 From: Santi <26361103+santigamo@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:05:54 +0200 Subject: [PATCH 08/11] test(docs): pin the canonical text against the bot's canonicaliser The Discord bot re-canonicalises the body it fetches from /api/docs and hashes it before comparing against our contentHash (nan-discord-bot, bot/docs_client.py:265 and bot/knowledge.py:390). Our output therefore has to be a fixed point of bot/knowledge.py::canonicalize_doc_text. canonicalParity.test.ts transcribes that function independently, as a code-point scan rather than a regex, so a typo on our side cannot be mirrored in the reference. It asserts the fixed point over every fixture and every doc, with strip_frontmatter both ways, and that no canonical body opens with `---` (rule: '-' serialises thematic breaks that way, and the bot's frontmatter regex would eat up to the next one). Doing this surfaced a real divergence: .trim() and str.strip() do not agree. JS strips U+FEFF, which Python keeps; Python strips U+001C..U+001F, which JS keeps. normalizeCanonicalText now uses pythonStrip(), matching the 29 code points Python's str.isspace() accepts. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/canonicalParity.test.ts | 130 ++++++++++++++++++++++++++++++++ src/lib/mdxToText.ts | 19 ++++- 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/lib/canonicalParity.test.ts diff --git a/src/lib/canonicalParity.test.ts b/src/lib/canonicalParity.test.ts new file mode 100644 index 0000000..7b3b907 --- /dev/null +++ b/src/lib/canonicalParity.test.ts @@ -0,0 +1,130 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { mdxToText, normalizeCanonicalText } from './mdxToText'; + +/** + * The text we serve from /api/docs is re-canonicalised and re-hashed by the + * Discord bot before it compares against our contentHash + * (nan-discord-bot, bot/docs_client.py:265 and bot/knowledge.py:390). + * + * If our output is not already a fixed point of the bot's canonicaliser, the + * two hashes never agree and every new manifest version re-indexes documents + * that did not change. + * + * What follows is a deliberately INDEPENDENT transcription of + * bot/knowledge.py::canonicalize_doc_text. It must not import our own + * normalizeCanonicalText, or the test would only be comparing that function + * against itself. The stripping is written as a code-point scan rather than a + * regex so that a typo on our side cannot be mirrored here. + */ + +// The 29 code points for which Python's str.isspace() is True. U+FEFF is not +// among them, and U+001C..U+001F are. +const PYTHON_SPACE = new Set([ + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x85, 0xa0, 0x1680, 0x2000, 0x2001, + 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, + 0x205f, 0x3000, +]); + +/** Python's str.strip() with no argument. */ +function pyStrip(s: string): string { + let start = 0; + let end = s.length; + while (start < end && PYTHON_SPACE.has(s.charCodeAt(start))) start += 1; + while (end > start && PYTHON_SPACE.has(s.charCodeAt(end - 1))) end -= 1; + return s.slice(start, end); +} + +// re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL) +const FRONTMATTER_RE = /^---\s*\n[\s\S]*?\n---\s*\n/; + +/** bot/knowledge.py::canonicalize_doc_text */ +function canonicalizeDocText(raw: string, { stripFrontmatter }: { stripFrontmatter: boolean }): string { + let text = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + if (stripFrontmatter) text = text.replace(FRONTMATTER_RE, ''); + text = text.replace(/\n{3,}/g, '\n\n'); + return pyStrip(text); +} + +const here = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(here, '__fixtures__'); +const docsDir = path.join(here, '..', 'content', 'docs'); + +function stripFrontmatterSource(raw: string): string { + return raw.replace(/^---[\s\S]*?\n---\s*\n/, ''); +} + +const fixtures = fs.readdirSync(fixturesDir).filter((f) => f.endsWith('.mdx')); +const docs = fs.readdirSync(docsDir).filter((f) => /\.(md|mdx)$/.test(f)); + +const corpus: Array<{ label: string; body: string }> = [ + ...fixtures.map((f) => ({ + label: `fixture ${f}`, + body: stripFrontmatterSource(fs.readFileSync(path.join(fixturesDir, f), 'utf8')), + })), + ...docs.map((f) => ({ + label: `doc ${f}`, + body: stripFrontmatterSource(fs.readFileSync(path.join(docsDir, f), 'utf8')), + })), +]; + +describe('mdxToText output is a fixed point of the bot canonicaliser', () => { + for (const { label, body } of corpus) { + // The happy path documented in canonicalize_doc_text's docstring. + it(`${label}: canonicalize(mdxToText(x)) === mdxToText(x)`, async () => { + const out = await mdxToText(body); + expect(canonicalizeDocText(out, { stripFrontmatter: false })).toBe(out); + }); + + // What docs_client.py actually calls on a fetched body. + it(`${label}: stable under strip_frontmatter=True too`, async () => { + const out = await mdxToText(body); + expect(canonicalizeDocText(out, { stripFrontmatter: true })).toBe(out); + }); + + // rule: '-' in the stringifier means a thematic break serialises to `---`. + // A canonical body opening with one would let the bot's frontmatter regex + // eat everything up to the next `---`. + it(`${label}: canonical text does not open with a frontmatter-shaped fence`, async () => { + const out = await mdxToText(body); + expect(out.startsWith('---')).toBe(false); + }); + } +}); + +describe('normalizeCanonicalText matches the independent Python replica', () => { + const inputs: Array<[string, string]> = [ + ['CRLF', 'a\r\nb\r\nc'], + ['lone CR', 'a\rb'], + ['three or more newlines', 'a\n\n\n\n\nb'], + ['leading and trailing spaces', ' a\nb '], + ['tabs and newlines around', '\n\t a \t\n'], + ['U+FEFF, which Python keeps', '\uFEFFtexto\uFEFF'], + ['U+001C, which Python strips', '\u001Ctexto\u001C'], + ['U+001F, which Python strips', '\u001Ftexto\u001F'], + ['NBSP', '\u00A0texto\u00A0'], + ['next line', '\u0085texto\u0085'], + ['ideographic space', '\u3000texto\u3000'], + ['line separator', '\u2028texto\u2028'], + ['empty', ''], + ['only whitespace', ' \n\t '], + ]; + + for (const [label, input] of inputs) { + it(label, () => { + expect(normalizeCanonicalText(input)).toBe(canonicalizeDocText(input, { stripFrontmatter: false })); + }); + } + + it('keeps U+FEFF, unlike JS .trim()', () => { + expect('\uFEFFx'.trim()).toBe('x'); + expect(normalizeCanonicalText('\uFEFFx')).toBe('\uFEFFx'); + }); + + it('strips U+001C, which JS .trim() keeps', () => { + expect('\u001Cx'.trim()).toBe('\u001Cx'); + expect(normalizeCanonicalText('\u001Cx')).toBe('x'); + }); +}); diff --git a/src/lib/mdxToText.ts b/src/lib/mdxToText.ts index a997a4d..e0ea4e0 100644 --- a/src/lib/mdxToText.ts +++ b/src/lib/mdxToText.ts @@ -489,10 +489,27 @@ function transformTree(root: { children?: unknown[] }, rateLimits: RateLimitsCon root.children = process(root as { type?: string; children?: unknown[] }); } +/** + * The 29 code points Python's str.isspace() accepts. + * + * JS .trim() is not the same set: it strips U+FEFF, which Python keeps, and + * keeps U+001C..U+001F, which Python strips. The Discord bot re-canonicalises + * the body we serve and hashes it (nan-discord-bot, bot/docs_client.py), so a + * mismatch here would make its hash disagree with our contentHash forever. + */ +const PYTHON_WHITESPACE = + '\\t\\n\\v\\f\\r\\x1c-\\x1f \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000'; +const PYTHON_STRIP_RE = new RegExp(`^[${PYTHON_WHITESPACE}]+|[${PYTHON_WHITESPACE}]+$`, 'g'); + +/** Mirrors Python's str.strip() rather than JS's .trim(). */ +export function pythonStrip(s: string): string { + return s.replace(PYTHON_STRIP_RE, ''); +} + export function normalizeCanonicalText(text: string): string { let s = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); s = s.replace(/\n{3,}/g, '\n\n'); - return s.trim(); + return pythonStrip(s); } export async function mdxToText( From 26a71eb34729e32a0f123823482bc43f1ae3f1be Mon Sep 17 00:00:00 2001 From: Santi <26361103+santigamo@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:21:43 +0200 Subject: [PATCH 09/11] docs: resync the content collection with main The branch had been frozen since May while main kept documenting new models and endpoints. Everything below existed only in the .astro pages this branch deletes, so merging without porting it would have removed it from production. models.mdx: mimo-v2.5, glm5.2, rerank and flux-2-klein as ModelCards; deepseek-v4-flash's monthly quota corrected from 100M to 500M tokens. api.mdx: POST /v1/rerank, POST /v1/images/generations and POST /v1/images/edits; reasoning_effort for deepseek-v4-flash; the new models in the endpoint index, in GET /v1/models and in the per-model capability card; the 400 and 403 error rows. apps.md: the Space tiers table (CPU, RAM, disk, pods, price). intro.md: 100 rpm was stale, it is 60. Two deliberate departures from main. The prose in the image rate limits section repeated "100 rpm / 5 concurrentes"; it now links to the rate limits section instead of restating numbers that already drifted once. And ModelCard renders `description` as plain text, not set:html, so the tags main used inside it were dropped rather than shipped literally. Verified against a running server: every docs page renders the new content, /api/docs serves it, If-None-Match still yields 304, and the manifest hashes match the per-document X-Content-Hash. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/content/docs/api.mdx | 322 ++++++++++++++++++++++++++++++++++-- src/content/docs/apps.md | 12 ++ src/content/docs/intro.md | 2 +- src/content/docs/models.mdx | 96 ++++++++++- 4 files changed, 417 insertions(+), 15 deletions(-) diff --git a/src/content/docs/api.mdx b/src/content/docs/api.mdx index 8652b49..804bdd4 100644 --- a/src/content/docs/api.mdx +++ b/src/content/docs/api.mdx @@ -27,9 +27,12 @@ Listado de los endpoints disponibles. Cada uno enlaza a su sección con `request { href: '#chat-completions', title: 'Chat completions', method: 'POST', path: '/v1/chat/completions' }, { href: '#completions', title: 'Text completions', method: 'POST', path: '/v1/completions' }, { href: '#embeddings', title: 'Embeddings', method: 'POST', path: '/v1/embeddings' }, + { href: '#rerank', title: 'Rerank', method: 'POST', path: '/v1/rerank' }, { href: '#audio-speech', title: 'Text-to-speech', method: 'POST', path: '/v1/audio/speech' }, { href: '#audio-transcriptions', title: 'Speech-to-text', method: 'POST', path: '/v1/audio/transcriptions' }, { href: '#responses', title: 'Responses', method: 'POST', path: '/v1/responses' }, + { href: '#images-generations', title: 'Generación de imágenes', method: 'POST', path: '/v1/images/generations' }, + { href: '#images-edits', title: 'Edición de imágenes', method: 'POST', path: '/v1/images/edits' }, ]} /> @@ -44,7 +47,7 @@ curl https://api.nan.builders/v1/models \

GET /v1/models

-Devuelve la lista de modelos disponibles para tu key. Modelos publicados: `deepseek-v4-flash`, `qwen3.6`, `gemma4`, `qwen3-embedding`, `kokoro`, `whisper`. +Devuelve la lista de modelos disponibles para tu key. Modelos publicados: `deepseek-v4-flash`, `mimo-v2.5`, `glm5.2`, `qwen3.6`, `gemma4`, `qwen3-embedding`, `rerank`, `kokoro`, `whisper`, `flux-2-klein` (incluye el modelo de imagen `flux-2-klein`). ### Request @@ -61,6 +64,12 @@ Sin body. Solo el header de autenticación. "object": "model", "created": 1677610602, "owned_by": "openai" + }, + { + "id": "glm5.2", + "object": "model", + "created": 1677610602, + "owned_by": "openai" } ] } @@ -75,14 +84,22 @@ curl https://api.nan.builders/v1/models \

POST /v1/chat/completions

-El endpoint principal de chat. Compatible con OpenAI Chat Completions. Modelos compatibles: `deepseek-v4-flash`, `qwen3.6` y `gemma4`. +El endpoint principal de chat. Compatible con OpenAI Chat Completions. Modelos compatibles: `deepseek-v4-flash`, `mimo-v2.5`, `glm5.2`, `qwen3.6` y `gemma4`. POST /v1/completions Endpoint legacy de OpenAI para text completion. Modelo compatible: `qwen3.6`. @@ -449,6 +514,106 @@ curl https://api.nan.builders/v1/embeddings \ }' ``` +

POST /v1/rerank

+ +Reordena una lista de documentos por relevancia a una query. Modelo compatible: `rerank` (`Qwen3-Reranker-8B`). Completa el stack RAG junto a `qwen3-embedding`: primero recuperas top-K por embeddings, después reordenas con `rerank`. Soporta 100+ idiomas, recuperación de código y búsqueda cross-lingual. Endpoint alias: `/v2/rerank`. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `model` | string · required | `rerank`. | +| `query` | string · required | Consulta contra la que se mide la relevancia de cada documento. | +| `documents` | array · required | Array de strings a reordenar. La respuesta los devuelve ordenados de mayor a menor `relevance_score` con su `index` original. | +| `top_n` | integer · optional | Limita la respuesta a los `N` documentos más relevantes. Por defecto devuelve todos. | + +### Response + +```json +{ + "id": "score-a032ee5767cab0ee", + "results": [ + { + "index": 0, + "relevance_score": 0.7390941977500916, + "document": { + "text": "Paris is the capital of France." + } + }, + { + "index": 1, + "relevance_score": 0.6002889275550842, + "document": { + "text": "Berlin is the capital of Germany." + } + }, + { + "index": 2, + "relevance_score": 0.12374333292245865, + "document": { + "text": "Madrid is the capital of Spain." + } + } + ], + "meta": { + "billed_units": { + "total_tokens": 43 + }, + "tokens": { + "input_tokens": 43 + } + } +} +``` + +La respuesta incluye `id`, `results` (array de `{index, relevance_score, document}`) y `meta` con `billed_units` y conteo de tokens. `relevance_score` está en el rango [0, 1]. El `index` se refiere a la posición original del documento en el array de entrada. + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/rerank \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "rerank", + "query": "What is the capital of France?", + "documents": [ + "Paris is the capital of France and home to the Eiffel Tower.", + "Berlin is the capital of Germany.", + "Madrid is the capital of Spain." + ] + }' +``` + +Con el SDK de `openai` de Python (usando `post` directo, ya que `rerank` no forma parte del cliente OpenAI): + +```python +import os +from openai import OpenAI + +client = OpenAI( + api_key=os.environ["NAN_API_KEY"], + base_url="https://api.nan.builders/v1" +) + +response = client.post( + path="/rerank", + cast_to=object, + body={ + "model": "rerank", + "query": "What is the capital of France?", + "documents": [ + "Paris is the capital of France and home to the Eiffel Tower.", + "Berlin is the capital of Germany.", + "Madrid is the capital of Spain.", + ], + }, +) + +for r in response["results"]: + print(r["index"], r["relevance_score"]) +``` +

POST /v1/audio/speech

Sintetiza audio a partir de texto (text-to-speech). Modelo compatible: `kokoro`. @@ -625,6 +790,137 @@ curl https://api.nan.builders/v1/responses \ El streaming en este endpoint actualmente entrega un único evento `response.completed` al final, no chunks incrementales. Para streaming token-a-token usa [/v1/chat/completions](#chat-completions) con `stream: true`. +

POST /v1/images/generations

+ +Genera imágenes a partir de texto (text-to-image). Compatible con la Images API de OpenAI. Modelo compatible: `flux-2-klein` (único modelo disponible hoy; el endpoint está diseñado para añadir más). El body es JSON. + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `prompt` | string · required | Descripción textual de la imagen a generar. | +| `model` | string · optional | Default `flux-2-klein` (único modelo disponible). Un modelo desconocido devuelve `404` (`model_not_found`). | +| `n` | integer · optional | Número de imágenes a generar, entre `1` y `4`. Default `1`. Un valor mayor que `4` devuelve `400`. | +| `size` | string · optional | Formato `"ANCHOxALTO"` con ambos lados divisibles por 16, cada uno entre 256 y 1536, y aspect ratio entre 1:3 y 3:1. Valores estándar como `1024x1024`, `1536x1024` o `1024x1536` funcionan. `"auto"` u omitido → `1024x1024`. | +| `response_format` | string · optional | `"url"` (default) o `"b64_json"`. Con `url` se devuelve un enlace temporal de R2 válido ~60 minutos (mismo contrato que OpenAI). Con `b64_json` se devuelven los bytes de la imagen en base64 inline. | + + + Por compatibilidad con SDKs de OpenAI se aceptan quality, style, background, moderation, output_format, output_compression y user, pero se ignoran — Flux no actúa sobre ellos. Además, stream: true no está soportado y devuelve 400. + + +### Parámetros adicionales (extensiones NaN) + +Estos parámetros **no** forman parte de la Images API de OpenAI. Con el SDK de `openai` se pasan vía `extra_body`. + +| Campo | Tipo | Descripción | +|---|---|---| +| `seed` | integer · optional | Seed base para reproducibilidad. Cada variante (cuando `n > 1`) parte de un offset sobre este valor. | +| `guidance` | number · optional | Guidance scale de FLUX. | + +### Response + +Mismo envoltorio que la Images API de OpenAI. `created` es el timestamp Unix en segundos. + +```json +{ + "created": 1778258200, + "data": [ + { "url": "https://...r2.../image.png" } + ] +} +``` + +Con `response_format=b64_json`, cada elemento de `data` es `{ "b64_json": "..." }` en lugar de `{ "url": "..." }`. + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/images/generations \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "flux-2-klein", + "prompt": "Un faro al atardecer sobre acantilados, estilo cinemático", + "size": "1024x1024" + }' +``` + +**response_format=b64_json:** + +```bash +curl https://api.nan.builders/v1/images/generations \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "flux-2-klein", + "prompt": "Un faro al atardecer sobre acantilados", + "size": "1024x1024", + "response_format": "b64_json" + }' +``` + +Con el SDK de `openai` en Python (las extensiones `seed` y `guidance` van en `extra_body`): + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-tu-key-aqui", + base_url="https://api.nan.builders/v1" +) + +response = client.images.generate( + model="flux-2-klein", + prompt="Un faro al atardecer sobre acantilados, estilo cinemático", + size="1024x1024", + n=1, + extra_body={"seed": 42, "guidance": 3.5} +) + +print(response.data[0].url) +``` + +### Rate limits y cuota + +La generación de imágenes **no pasa por LiteLLM**, así que los límites por key de LiteLLM (ver [Rate limits](#rate-limits)) **no** le aplican. Generar imágenes no consume tu presupuesto de rpm del chat, y viceversa. Estos límites aplican igual a la API y a la consola web, y son propios de los endpoints de imágenes: + +| Límite | Valor | Descripción | +|---|---|---| +| Rate limit | `1 req/s · burst 3` | 1 request por segundo sostenido, con burst de hasta 3 (puedes disparar hasta 3 generaciones seguidas sin error). Al excederlo devuelve `429` (`rate_limit_exceeded`). | +| Cuota mensual | `100 requests / mes` | 100 requests por mes y por usuario (1 request = 1 uso, independientemente del valor de `n`). Al excederla devuelve `429` (`insufficient_quota`). Esta cuota es independiente del límite de 500M tokens/mes del chat. | +| Tier | `inference` | Requiere membresía de tier inference. Las keys de tier community reciben `403` (`tier_restricted`). | + +

POST /v1/images/edits

+ +Genera una imagen a partir de una o varias imágenes de referencia (image-to-image). Compatible con la Images API de OpenAI. Modelo compatible: `flux-2-klein`. La petición es `multipart/form-data`. Aplican la misma membresía inference-tier y la misma cuota mensual de 100 requests que [/v1/images/generations](#images-generations). + +### Request + +| Campo | Tipo | Descripción | +|---|---|---| +| `image` / `image[]` | file · required | Una o más imágenes de referencia (hasta 4; las extras se descartan). PNG, JPEG o WebP, cada una < 25 MB. | +| `prompt` | string · required | Descripción de la edición o transformación a aplicar. | +| `model`, `n`, `size`, `response_format` | optional | Mismo comportamiento que en [/v1/images/generations](#images-generations). Las extensiones `seed` y `guidance` también se aceptan (como campos del form). | + + + El parámetro mask no está soportado y devuelve 400 — Flux Klein no hace inpainting. + + +### Response + +Mismo envoltorio que [/v1/images/generations](#images-generations): `{ "created": ..., "data": [{ "url": "..." }] }` (o elementos `{ "b64_json": "..." }` con `response_format=b64_json`). + +### Ejemplo + +```bash +curl https://api.nan.builders/v1/images/edits \ + -H "Authorization: Bearer sk-tu-key-aqui" \ + -F "model=flux-2-klein" \ + -F "image[]=@ref.png" \ + -F "prompt=Convierte la escena en invierno con nieve" \ + -F "size=1024x1024" +``` + ## Errores Los errores siguen el formato estándar de OpenAI: HTTP status no-2xx con un body JSON describiendo el problema. @@ -642,9 +938,11 @@ Los errores siguen el formato estándar de OpenAI: HTTP status no-2xx con un bod | Código | Descripción | |---|---| -| 401 | Header `Authorization` inválido o ausente. | -| 404 | Modelo no existe (campo `model`). | -| 429 | Rate limit excedido — `rpm_limit` o `max_parallel_requests`. | +| 400 | Parámetro inválido — el body incluye `param` con el campo que falló (p. ej. `prompt`, `n`, `size`, `stream`, `mask` o `image` en los endpoints de imágenes). El filtro de seguridad devuelve `content_policy_violation`. | +| 401 | Header `Authorization` inválido o ausente (`invalid_api_key`). | +| 403 | Tu tier no tiene acceso al endpoint (`tier_restricted`). La generación de imágenes requiere membresía inference. | +| 404 | Modelo no existe (campo `model`, `model_not_found`). | +| 429 | Rate limit excedido — `rpm_limit` o `max_parallel_requests` (`rate_limit_exceeded`), o cuota mensual agotada (`quota_exceeded` / `insufficient_quota`, como la de 100 requests de imágenes). | | 500 | Error interno (incluye errores upstream del modelo). | | 524 | Timeout (típico con audios grandes en [/v1/audio/transcriptions](#audio-transcriptions)). | diff --git a/src/content/docs/apps.md b/src/content/docs/apps.md index f97cdc7..bbf719f 100644 --- a/src/content/docs/apps.md +++ b/src/content/docs/apps.md @@ -11,6 +11,18 @@ NaN Cloud te permite **desplegar tus propias apps desde un repositorio de GitHub > **Antes de empezar** > Las Apps viven dentro de un **Space**: tu propio entorno con su cuota de recursos. Si tienes la suscripción de inferencia activa, recibes **un Space Basic gratis** incluido en tu membresía. Si no, puedes comprar uno desde [cloud.nan.builders/spaces](https://cloud.nan.builders/spaces). +## Tiers disponibles + +Cada Space pertenece a un tier. El tier define la cuota total de CPU, RAM y almacenamiento que se reparte entre todas las apps que despliegues dentro de él. Puedes **subir o bajar de tier** en cualquier momento desde el dashboard del Space (la bajada solo se permite si tu uso actual cabe en el tier nuevo). + +| Tier | CPU | RAM | Disco | Pods | Precio | +|---|---|---|---|---|---| +| Basic | 2 vCPU | 4 GiB | 20 GiB | 5 | Gratis con inferencia · $6 / €6 al mes | +| Medium | 4 vCPU | 8 GiB | 40 GiB | 10 | $12 / €12 al mes | +| Large | 4 vCPU | 16 GiB | 80 GiB | 20 | $24 / €24 al mes | + +CPU y RAM son los **topes agregados del Space** (suma de todas tus apps). Por defecto cada app que crees arranca con un límite cómodo de `500m` CPU y `500 MiB` de RAM, suficiente para una API o worker típico; puedes subir el límite por app desde la sección *Advanced options* del formulario hasta consumir el tier completo. El disco se reparte vía PVCs (5/10/20 según tier) y solo lo usan las apps que marques como *persistent*. + ## 1. Crear un Space Entra en [cloud.nan.builders/spaces](https://cloud.nan.builders/spaces). Si eres miembro de inferencia verás un panel que te ofrece reclamar tu Space Basic gratis: elige un *slug* (entre 1 y 20 caracteres, minúsculas, sin espacios) y pulsa **Claim free Basic**. El slug se usará para construir los dominios públicos de tus apps, así que escógelo con cariño. diff --git a/src/content/docs/intro.md b/src/content/docs/intro.md index c9e807e..d123131 100644 --- a/src/content/docs/intro.md +++ b/src/content/docs/intro.md @@ -15,7 +15,7 @@ Esta doc explica cómo conectar tus herramientas a nuestras GPUs. El cluster cor | Métrica | Valor | |---|---| -| Requests por minuto | 100 rpm | +| Requests por minuto | 60 rpm | | Paralelo máximo | 5 concurrentes | ## Qué hacer a continuación diff --git a/src/content/docs/models.mdx b/src/content/docs/models.mdx index e4b4d08..92c2938 100644 --- a/src/content/docs/models.mdx +++ b/src/content/docs/models.mdx @@ -19,12 +19,12 @@ con el mismo `base URL`. tag="284B-21B" leftLabel="generación de texto y chat" rightLabel="capacidades" - description="Modelo MoE de 284B parámetros (21B activos). Contexto de 1M tokens. Tool calling y reasoning. Cuota mensual de 100M tokens por miembro." + description="Modelo MoE de 284B parámetros (21B activos). Contexto de 1M tokens. Tool calling y reasoning. Cuota mensual de 500M tokens por miembro." specs={[ { label: 'Tipo', value: 'MoE (284B total · 21B active)' }, { label: 'Cuantización', value: 'FP8' }, { label: 'Contexto', value: '1M tokens' }, - { label: 'Cuota mensual', value: '100M tokens / miembro' }, + { label: 'Cuota mensual', value: '500M tokens / miembro' }, ]} items={[ 'Tool calling', @@ -34,6 +34,56 @@ con el mismo `base URL`. ]} /> +max_tokens ≥ 300
)', + 'Visión (image input)', + 'Audio (audio input)', + 'Contexto de 1M tokens', + 'Generación streaming (SSE)', + ]} +/> + + + + + +/v1/images/generations)', + 'Image-to-image con hasta 4 referencias (/v1/images/edits)', + 'Salida como URL temporal (R2, ~60 min) o base64', + 'Reproducibilidad por seed y control de guidance', + ]} +/> + From e0c2eeca439e1cc65a4df6a1d92929cd0efb258d Mon Sep 17 00:00:00 2001 From: Santi <26361103+santigamo@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:02:07 +0200 Subject: [PATCH 10/11] fix(docs): close the findings from the sign-off review Computed object keys were still losing content silently. `{ [label]: 'x' }` has an Identifier key, so astToValue() read it as the literal field `label`, wrote the wrong key, and blanked out the one the component expected: export const label = 'name' -> "- `` - *string* - desc" astToValue() now rejects computed keys, with a regression test. examples.md lost `id="qwen36-zed"` in the conversion, so the "config completo arriba" link pointed at an id the markdown heading never generated. Restored the explicit id rather than repointing the link, which keeps existing deep links working. The TOC is built from the DOM, so a raw

still appears. Rate limits are now deploy-reproducible: the values live in wrangler.jsonc under `vars`. Renamed away from PUBLIC_*, which in Astro means "exposed to the client through import.meta.env" and would mislead, since these are read server-side from the Workers env. Also: `node.js` and `zed` are not Shiki languages and fell back to plaintext; dropped the unused `unist-util-visit`; and `z` from `astro:content` is deprecated, so content.config.ts imports it from `astro/zod` as Astro asks. That takes `astro check` from 7 hints to 1, and the last one is in a landing component this branch does not touch. Verified with the repo's CI steps on Node 22: npm ci, npm test (275), npx astro check (0 errors), npm run build. Every docs page renders with no broken internal anchors, and /api/docs serves no raw HTML or entities. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 11 ++++++----- package-lock.json | 7 +++---- package.json | 3 +-- src/content.config.ts | 3 ++- src/content/docs/examples.md | 12 ++++++------ src/env.d.ts | 4 ++-- src/lib/mdxToText.test.ts | 6 ++++++ src/lib/mdxToText.ts | 13 ++++++++++++- src/lib/rateLimits.test.ts | 10 +++++----- src/lib/rateLimits.ts | 12 ++++++------ wrangler.jsonc | 8 ++++++++ 11 files changed, 57 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index c6d935b..626855b 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,9 @@ RESEND_API_KEY= RESEND_FROM_EMAIL=no-reply@nan.builders # Per-key rate limits shown in the docs and served to the Discord bot. -# Not secrets. Optional — omit to use the defaults in src/lib/rateLimits.ts -# (60 rpm, 5 concurrentes). Changing them changes the docs contentHash, so -# the bot re-syncs on its next manifest poll. -PUBLIC_RATE_LIMIT_RPM=60 -PUBLIC_RATE_LIMIT_PARALLEL=5 +# Not secrets: the deployed values live in wrangler.jsonc under `vars`, and +# src/lib/rateLimits.ts falls back to the same numbers when they are missing. +# Override here only to see a different value in `astro dev`. Changing them +# changes the docs contentHash, so the bot re-syncs on its next manifest poll. +RATE_LIMIT_RPM=60 +RATE_LIMIT_PARALLEL=5 diff --git a/package-lock.json b/package-lock.json index 81b0cd8..11ded6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nan-website", - "version": "0.0.3", + "version": "0.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nan-website", - "version": "0.0.3", + "version": "0.0.7", "dependencies": { "@astrojs/cloudflare": "^13.1.3", "@astrojs/mdx": "^5.0.6", @@ -22,8 +22,7 @@ "remark-stringify": "^11.0.0", "shiki": "^4.0.2", "tailwindcss": "^4.2.2", - "unified": "^11.0.5", - "unist-util-visit": "^5.1.0" + "unified": "^11.0.5" }, "devDependencies": { "@astrojs/check": "^0.9.8", diff --git a/package.json b/package.json index ebc980b..cd65f8f 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,7 @@ "remark-stringify": "^11.0.0", "shiki": "^4.0.2", "tailwindcss": "^4.2.2", - "unified": "^11.0.5", - "unist-util-visit": "^5.1.0" + "unified": "^11.0.5" }, "devDependencies": { "@astrojs/check": "^0.9.8", diff --git a/src/content.config.ts b/src/content.config.ts index 80ab200..bbf43e8 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,5 +1,6 @@ -import { defineCollection, z } from 'astro:content'; +import { defineCollection } from 'astro:content'; import { glob } from 'astro/loaders'; +import { z } from 'astro/zod'; const docs = defineCollection({ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/docs' }), diff --git a/src/content/docs/examples.md b/src/content/docs/examples.md index 83adb78..3ec003e 100644 --- a/src/content/docs/examples.md +++ b/src/content/docs/examples.md @@ -52,7 +52,7 @@ Instalar: `pip install openai` ### node.js (openai) -```node.js +```javascript import OpenAI from "openai"; const client = new OpenAI({ @@ -237,9 +237,9 @@ Config para `~/.openclaw/openclaw.json` `maxTokens: 65536` es el máximo que soporta el modelo. `params.maxTokens: 16000` es lo que se envía por request. 16K es un buen balance para la mayoría de tareas. Si necesitas respuestas más largas, súbelo — pero ten en cuenta que el reasoning también consume de ese presupuesto. -### settings.json (Zed) +

settings.json (Zed)

-```zed +```json { "language_models": { "openai": { @@ -309,7 +309,7 @@ print(len(embeddings[0])) // 4096 ### node.js -```node.js +```javascript import OpenAI from "openai"; const client = new OpenAI({ @@ -434,7 +434,7 @@ response = client.audio.speech.create( ### node.js -```node.js +```javascript import OpenAI from "openai"; import fs from "fs"; @@ -512,7 +512,7 @@ print(translation.text) # English translation ### node.js -```node.js +```javascript import OpenAI from "openai"; import fs from "fs"; import FormData from "form-data"; diff --git a/src/env.d.ts b/src/env.d.ts index 758c875..4addc10 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -11,8 +11,8 @@ declare namespace Cloudflare { CLOUD_API_URL: string; CLOUD_API_WAITLIST_KEY: string; // Optional: both fall back to the defaults in src/lib/rateLimits.ts. - PUBLIC_RATE_LIMIT_RPM?: string; - PUBLIC_RATE_LIMIT_PARALLEL?: string; + RATE_LIMIT_RPM?: string; + RATE_LIMIT_PARALLEL?: string; } } diff --git a/src/lib/mdxToText.test.ts b/src/lib/mdxToText.test.ts index a24193b..df03f75 100644 --- a/src/lib/mdxToText.test.ts +++ b/src/lib/mdxToText.test.ts @@ -138,6 +138,12 @@ describe('mdxToText error cases', () => { await expect(mdxToText(input)).rejects.toThrow(/template literal with interpolation/); }); + it('throws for a computed object key, which would blank out the real field', async () => { + const input = + "export const label = 'name';\n\n\n"; + await expect(mdxToText(input)).rejects.toThrow(/computed object key/); + }); + it('throws for a spread attribute', async () => { const input = "import Callout from './x';\n\n\n\ntexto\n\n\n"; await expect(mdxToText(input)).rejects.toThrow(/spread attribute/); diff --git a/src/lib/mdxToText.ts b/src/lib/mdxToText.ts index e0ea4e0..828d6b6 100644 --- a/src/lib/mdxToText.ts +++ b/src/lib/mdxToText.ts @@ -88,10 +88,21 @@ export function astToValue(node: unknown): unknown { const obj: Record = {}; const props = (n.properties as unknown[] | undefined) ?? []; for (const p of props) { - const prop = p as { type: string; key: { type: string; name?: string; value?: unknown }; value: unknown }; + const prop = p as { + type: string; + computed?: boolean; + key: { type: string; name?: string; value?: unknown }; + value: unknown; + }; if (prop.type !== 'Property') { throw new Error(`Unsupported MDX expression: ${prop.type} in object literal`); } + // `{ [label]: 'x' }` has an Identifier key too, but it names a binding + // rather than the field. Reading it as a literal would silently write + // the wrong key and blank out the one the component expects. + if (prop.computed) { + throw new Error('Unsupported MDX expression: computed object key'); + } let key: string; if (prop.key.type === 'Identifier') key = prop.key.name as string; else key = String(astToValue(prop.key)); diff --git a/src/lib/rateLimits.test.ts b/src/lib/rateLimits.test.ts index 7cb1e38..de6190a 100644 --- a/src/lib/rateLimits.test.ts +++ b/src/lib/rateLimits.test.ts @@ -12,14 +12,14 @@ describe('getRateLimitsConfig', () => { it('reads both per-key limits from the env', () => { const config = getRateLimitsConfig({ - PUBLIC_RATE_LIMIT_RPM: '120', - PUBLIC_RATE_LIMIT_PARALLEL: '8', + RATE_LIMIT_RPM: '120', + RATE_LIMIT_PARALLEL: '8', }); expect(config.perKey).toEqual({ requestsPerMinute: 120, maxParallel: 8 }); }); it('leaves the per-model tables untouched', () => { - const config = getRateLimitsConfig({ PUBLIC_RATE_LIMIT_RPM: '120' }); + const config = getRateLimitsConfig({ RATE_LIMIT_RPM: '120' }); expect(config.tokensPerMinuteByModel).toEqual(DEFAULT_RATE_LIMITS.tokensPerMinuteByModel); expect(config.requestsPerMinuteByModel).toEqual(DEFAULT_RATE_LIMITS.requestsPerMinuteByModel); }); @@ -28,7 +28,7 @@ describe('getRateLimitsConfig', () => { 'ignores the invalid value %s and warns instead of taking the docs down', (raw) => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const config = getRateLimitsConfig({ PUBLIC_RATE_LIMIT_RPM: raw }); + const config = getRateLimitsConfig({ RATE_LIMIT_RPM: raw }); expect(config.perKey.requestsPerMinute).toBe(DEFAULT_RATE_LIMITS.perKey.requestsPerMinute); expect(warn).toHaveBeenCalledOnce(); }, @@ -36,7 +36,7 @@ describe('getRateLimitsConfig', () => { it('treats an empty string as absent, without warning', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const config = getRateLimitsConfig({ PUBLIC_RATE_LIMIT_PARALLEL: ' ' }); + const config = getRateLimitsConfig({ RATE_LIMIT_PARALLEL: ' ' }); expect(config.perKey.maxParallel).toBe(DEFAULT_RATE_LIMITS.perKey.maxParallel); expect(warn).not.toHaveBeenCalled(); }); diff --git a/src/lib/rateLimits.ts b/src/lib/rateLimits.ts index 527fe1c..c454303 100644 --- a/src/lib/rateLimits.ts +++ b/src/lib/rateLimits.ts @@ -27,8 +27,8 @@ export interface RateLimitsConfig { } export interface RateLimitsEnv { - PUBLIC_RATE_LIMIT_RPM?: string; - PUBLIC_RATE_LIMIT_PARALLEL?: string; + RATE_LIMIT_RPM?: string; + RATE_LIMIT_PARALLEL?: string; } /** @@ -62,14 +62,14 @@ export function getRateLimitsConfig(env: RateLimitsEnv = {}): RateLimitsConfig { ...DEFAULT_RATE_LIMITS, perKey: { requestsPerMinute: parsePositiveInt( - env.PUBLIC_RATE_LIMIT_RPM, + env.RATE_LIMIT_RPM, DEFAULT_RATE_LIMITS.perKey.requestsPerMinute, - 'PUBLIC_RATE_LIMIT_RPM', + 'RATE_LIMIT_RPM', ), maxParallel: parsePositiveInt( - env.PUBLIC_RATE_LIMIT_PARALLEL, + env.RATE_LIMIT_PARALLEL, DEFAULT_RATE_LIMITS.perKey.maxParallel, - 'PUBLIC_RATE_LIMIT_PARALLEL', + 'RATE_LIMIT_PARALLEL', ), }, }; diff --git a/wrangler.jsonc b/wrangler.jsonc index 24c3381..1ca66a7 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -9,5 +9,13 @@ }, "observability": { "enabled": true + }, + // Per-key rate limits shown in the docs and served to the Discord bot. + // Not secrets. Kept here so the deployed values are reproducible from the + // repo; src/lib/rateLimits.ts falls back to the same numbers if they are + // missing. + "vars": { + "RATE_LIMIT_RPM": "60", + "RATE_LIMIT_PARALLEL": "5" } } From 278db7ad0ead94ac944c559570c973321dd5e943 Mon Sep 17 00:00:00 2001 From: Santi <26361103+santigamo@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:49:24 +0200 Subject: [PATCH 11/11] fix(docs): controlled 500s and a fail-fast manifest in the docs API Two refinements from the sign-off review of PR #13. Both public routes now wrap their handlers in try/catch: an unexpected mdxToText failure logs the slug plus the error and returns a controlled 500 with no internal details in the body, instead of an unhandled 500. The invalid-slug 400 stays outside the try. Nothing is skipped or degraded: one bad doc still takes down manifest.json loudly, by design. manifest.json no longer filters non-SAFE_SLUG ids silently; it throws, so a doc whose effective id has a slash can never quietly vanish from the manifest. The id a doc actually gets is decided by the glob loader, which honours a frontmatter `slug:` verbatim before schema parsing and slugifies path segments otherwise, so the CI corpus guard mirrors the loader exactly: same frontmatter parser (@astrojs/markdown-remark), same discovery library, pattern and options (tinyglobby), same per-segment github-slugger and trailing /index collapse. Mechanism self-tests pin each branch: frontmatter overrides in any YAML form, path normalization, dot-entry exclusion and directory symlinks. New route tests exercise the real GET handlers with the real mdxToText throwing on a bare MDX expression: 400/404/304/200 behaviour is unchanged and the 500s are controlled and logged. Verified: npm test (294), npx astro check (0 errors), npm run build, and a dev-server smoke run (manifest 200 with 7 entries, 304 on If-None-Match, 400 on traversal). Co-Authored-By: Claude Fable 5 --- src/lib/docsManifestRoute.test.ts | 216 ++++++++++++++++++++++++++++ src/lib/docsSlugRoute.test.ts | 66 +++++++++ src/pages/api/docs/[slug].md.ts | 47 +++--- src/pages/api/docs/manifest.json.ts | 94 ++++++------ 4 files changed, 360 insertions(+), 63 deletions(-) create mode 100644 src/lib/docsManifestRoute.test.ts create mode 100644 src/lib/docsSlugRoute.test.ts diff --git a/src/lib/docsManifestRoute.test.ts b/src/lib/docsManifestRoute.test.ts new file mode 100644 index 0000000..a61df50 --- /dev/null +++ b/src/lib/docsManifestRoute.test.ts @@ -0,0 +1,216 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseFrontmatter } from '@astrojs/markdown-remark'; +import { slug as githubSlug } from 'github-slugger'; +import { globSync } from 'tinyglobby'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SAFE_SLUG } from './docsApi'; + +// astro:content and cloudflare:workers are virtual modules that only exist +// under Astro's Vite plugin, so the route module cannot be imported without +// mocking them first. The factory references are hoisted with vi.hoisted so +// they are available when vi.mock runs before the import below. +const { getCollectionMock } = vi.hoisted(() => ({ getCollectionMock: vi.fn() })); +vi.mock('astro:content', () => ({ getCollection: getCollectionMock })); +vi.mock('cloudflare:workers', () => ({ env: {} })); + +// Import AFTER the mocks so the handler binds to them. +import { GET } from '../pages/api/docs/manifest.json'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const docsDir = path.join(here, '..', 'content', 'docs'); + +/** + * Astro's glob loader (base ./src/content/docs, pattern **\/*.{md,mdx}) derives + * an entry id from the frontmatter `slug` if present — verbatim and BEFORE + * schema parsing, so the collection schema cannot veto it (generateIdDefault in + * astro/dist/content/loaders/glob.js) — and otherwise from the file path + * relative to the base — each path segment slugified with github-slugger, + * joined with '/', and a trailing '/index' collapsed (getContentEntryIdAndSlug + * in astro/dist/content/utils.js). Both branches are mirrored here, using the + * same frontmatter parser Astro itself uses for entries + * (astro/dist/content/utils.js imports it from @astrojs/markdown-remark), so a + * nested guides/foo.md and a flat doc declaring `slug: guides/foo` in any + * YAML form each yield an id the route now rejects, and CI fails at PR time + * the moment someone adds a doc whose effective id is not slug-safe — while a + * doc that Astro itself would normalize to a safe id keeps passing. + */ +function frontmatterSlug(file: string): string | undefined { + const { frontmatter } = parseFrontmatter(fs.readFileSync(file, 'utf8')); + return frontmatter.slug ? String(frontmatter.slug) : undefined; +} + +function collectDocIds(dir: string): string[] { + // Identical file discovery to the loader: same library, same pattern, same + // options (astro/dist/content/loaders/glob.js), so dotfile handling, symlink + // following and matching semantics cannot diverge. + const files = globSync('**/*.{md,mdx}', { cwd: dir, expandDirectories: false }); + return files.map((rel) => { + const pathId = rel + .replace(/\.(md|mdx)$/, '') + .split('/') + .map((segment) => githubSlug(segment)) + .join('/') + .replace(/\/index$/, ''); + return frontmatterSlug(path.join(dir, rel)) ?? pathId; + }); +} + +const corpusIds = collectDocIds(docsDir); + +function entry(id: string, body = '# Titulo\n\nContenido de prueba.\n') { + return { + id, + body, + data: { title: `Titulo ${id}`, description: `Descripcion ${id}`, order: 0, locale: 'es' }, + }; +} + +function ctx(headers: Record = {}) { + const request = new Request('https://nan.builders/api/docs/manifest.json', { headers }); + return { request } as never; +} + +describe('docs corpus ids are slug-safe', () => { + it('finds at least one real doc to guard', () => { + expect(corpusIds.length).toBeGreaterThan(0); + }); + + it('derives ids the way the glob loader does, including frontmatter slug overrides', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-guard-')); + try { + fs.writeFileSync(path.join(tmp, 'flat.md'), '---\ntitle: T\nslug: guides/foo\n---\n\nBody\n'); + fs.writeFileSync(path.join(tmp, 'flow.md'), '---\n{ title: T, slug: guides/flow }\n---\n\nBody\n'); + fs.writeFileSync(path.join(tmp, 'quoted.md'), '---\ntitle: T\n"slug": guides/quoted\n---\n\nBody\n'); + fs.writeFileSync(path.join(tmp, 'crlf.md'), '---\r\ntitle: T\r\nslug: guides/crlf\r\n---\r\n\r\nBody\r\n'); + fs.writeFileSync(path.join(tmp, 'Foo Bar.md'), '---\ntitle: T\n---\n\nBody\n'); + fs.mkdirSync(path.join(tmp, 'guides')); + fs.writeFileSync(path.join(tmp, 'guides', 'nested.md'), '---\ntitle: T\n---\n\nBody\n'); + fs.writeFileSync(path.join(tmp, 'guides', 'index.md'), '---\ntitle: T\n---\n\nBody\n'); + const ids = collectDocIds(tmp).sort(); + expect(ids).toEqual([ + 'foo-bar', + 'guides', + 'guides/crlf', + 'guides/flow', + 'guides/foo', + 'guides/nested', + 'guides/quoted', + ]); + const unsafe = ids.filter((id) => !SAFE_SLUG.test(id)); + expect(unsafe).toEqual(['guides/crlf', 'guides/flow', 'guides/foo', 'guides/nested', 'guides/quoted']); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('excludes dot-prefixed files and directories like the glob loader', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-guard-')); + try { + fs.writeFileSync(path.join(tmp, 'visible.md'), '---\ntitle: T\n---\n\nBody\n'); + fs.writeFileSync(path.join(tmp, '.hidden.md'), '---\ntitle: T\nslug: guides/hidden\n---\n\nBody\n'); + fs.mkdirSync(path.join(tmp, '.draft', 'guides'), { recursive: true }); + fs.writeFileSync(path.join(tmp, '.draft', 'guides', 'foo.md'), '---\ntitle: T\n---\n\nBody\n'); + expect(collectDocIds(tmp)).toEqual(['visible']); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('follows directory symlinks like the glob loader', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-guard-')); + try { + const real = path.join(tmp, 'real-docs'); + const base = path.join(tmp, 'docs'); + fs.mkdirSync(real); + fs.mkdirSync(base); + fs.writeFileSync(path.join(real, 'foo.md'), '---\ntitle: T\n---\n\nBody\n'); + fs.symlinkSync(real, path.join(base, 'linked'), 'dir'); + const ids = collectDocIds(base); + expect(ids).toEqual(['linked/foo']); + expect(SAFE_SLUG.test(ids[0])).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('ignores slug keys that are not top-level, matching the loader', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-guard-')); + try { + fs.writeFileSync( + path.join(tmp, 'inner.md'), + '---\ntitle: T\nmeta:\n slug: guides/inner\n---\n\nBody\n', + ); + expect(collectDocIds(tmp)).toEqual(['inner']); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + for (const id of corpusIds) { + it(`id "${id}" matches SAFE_SLUG`, () => { + expect(SAFE_SLUG.test(id)).toBe(true); + }); + } +}); + +describe('GET /api/docs/manifest.json', () => { + beforeEach(() => { + getCollectionMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('fails loud with a controlled 500 when a doc id is not slug-safe', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + getCollectionMock.mockResolvedValue([entry('intro'), entry('guides/foo')]); + + const res = await GET(ctx()); + + expect(res.status).toBe(500); + expect(await res.text()).toBe('Internal error'); + expect(errorSpy).toHaveBeenCalledWith('[api/docs] failed to build manifest.json', expect.any(Error)); + const loggedError = errorSpy.mock.calls[0][1] as Error; + expect(loggedError.message).toContain('guides/foo'); + }); + + it('returns a controlled 500 (not an unhandled rejection) when mdxToText throws', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + getCollectionMock.mockResolvedValue([entry('intro', 'texto\n\n{oops}\n')]); + + const res = await GET(ctx()); + + expect(res.status).toBe(500); + expect(await res.text()).toBe('Internal error'); + expect(errorSpy).toHaveBeenCalledOnce(); + const loggedError = errorSpy.mock.calls[0][1] as Error; + expect(loggedError.message).toContain('{oops}'); + }); + + it('returns 200 with a well-formed manifest for valid entries', async () => { + getCollectionMock.mockResolvedValue([ + entry('intro', '# Intro\n\nHola mundo.\n'), + entry('api', '# API\n\nContenido.\n'), + ]); + + const res = await GET(ctx()); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.version).toBe('string'); + expect(body.version.startsWith('sha256:')).toBe(true); + expect(Array.isArray(body.entries)).toBe(true); + expect(body.entries.length).toBe(2); + for (const e of body.entries) { + expect(e).toHaveProperty('slug'); + expect(e).toHaveProperty('contentHash'); + expect(e).toHaveProperty('contentUrl'); + expect(e.contentHash.startsWith('sha256:')).toBe(true); + expect(e.contentUrl).toBe(`/api/docs/${e.slug}.md`); + } + }); +}); diff --git a/src/lib/docsSlugRoute.test.ts b/src/lib/docsSlugRoute.test.ts new file mode 100644 index 0000000..42894e8 --- /dev/null +++ b/src/lib/docsSlugRoute.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// astro:content and cloudflare:workers are virtual modules that only exist +// under Astro's Vite plugin, so they must be mocked before the route import. +vi.mock('cloudflare:workers', () => ({ env: {} })); +vi.mock('astro:content', () => ({ getEntry: vi.fn() })); + +import { getEntry } from 'astro:content'; +import { GET } from '../pages/api/docs/[slug].md'; + +const getEntryMock = getEntry as unknown as ReturnType; + +function ctx(slug: string | undefined, init?: { ifNoneMatch?: string }) { + const headers = new Headers(); + if (init?.ifNoneMatch) headers.set('if-none-match', init.ifNoneMatch); + const request = new Request(`https://nan.builders/api/docs/${slug ?? ''}.md`, { headers }); + return { params: { slug }, request } as never; +} + +describe('GET /api/docs/[slug].md', () => { + afterEach(() => { + vi.restoreAllMocks(); + getEntryMock.mockReset(); + }); + + it('rejects an invalid slug with 400 before touching the collection', async () => { + const resp = await GET(ctx('Not A Slug')); + expect(resp.status).toBe(400); + expect(await resp.text()).toBe('Invalid slug'); + expect(getEntryMock).not.toHaveBeenCalled(); + }); + + it('renders a valid entry body to 200 markdown', async () => { + getEntryMock.mockResolvedValue({ body: '# Hello\n\nWorld' }); + + const resp = await GET(ctx('intro')); + expect(resp.status).toBe(200); + expect(resp.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8'); + const body = await resp.text(); + expect(body).toContain('# Hello'); + expect(body).toContain('World'); + }); + + it('returns a loud 500 when mdxToText throws on a bare MDX expression', async () => { + // `{oops}` parses to an mdxFlowExpression/mdxTextExpression node, which the + // real mdxToText refuses rather than silently dropping — the genuine failure + // path, exercised without mocking mdxToText itself. + getEntryMock.mockResolvedValue({ body: 'before\n\n{oops}\n\nafter' }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const resp = await GET(ctx('breaks')); + + expect(resp.status).toBe(500); + expect(await resp.text()).toBe('Internal error'); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(String(errorSpy.mock.calls[0][0])).toContain('breaks'); + }); + + it('returns 404 when the entry is missing', async () => { + getEntryMock.mockResolvedValue(undefined); + + const resp = await GET(ctx('ghost')); + expect(resp.status).toBe(404); + expect(await resp.text()).toBe('Not found'); + }); +}); diff --git a/src/pages/api/docs/[slug].md.ts b/src/pages/api/docs/[slug].md.ts index e826563..5d5b6d2 100644 --- a/src/pages/api/docs/[slug].md.ts +++ b/src/pages/api/docs/[slug].md.ts @@ -15,34 +15,39 @@ export const GET: APIRoute = async ({ params, request }) => { return new Response('Invalid slug', { status: 400 }); } - const entry = await getEntry('docs', slug); - if (!entry) { - return new Response('Not found', { status: 404 }); - } + try { + const entry = await getEntry('docs', slug); + if (!entry) { + return new Response('Not found', { status: 404 }); + } + + const body = await mdxToText(entry.body ?? '', getRateLimitsConfig(env)); + const contentHash = `sha256:${await sha256Hex(body)}`; + const etag = quoteEtag(contentHash); - const body = await mdxToText(entry.body ?? '', getRateLimitsConfig(env)); - const contentHash = `sha256:${await sha256Hex(body)}`; - const etag = quoteEtag(contentHash); + const ifNoneMatch = request.headers.get('if-none-match'); + if (ifNoneMatchMatches(ifNoneMatch, etag)) { + return new Response(null, { + status: 304, + headers: { + 'Cache-Control': DOCS_CACHE_CONTROL, + 'ETag': etag, + 'X-Content-Hash': contentHash, + }, + }); + } - const ifNoneMatch = request.headers.get('if-none-match'); - if (ifNoneMatchMatches(ifNoneMatch, etag)) { - return new Response(null, { - status: 304, + return new Response(body, { + status: 200, headers: { + 'Content-Type': 'text/markdown; charset=utf-8', 'Cache-Control': DOCS_CACHE_CONTROL, 'ETag': etag, 'X-Content-Hash': contentHash, }, }); + } catch (error) { + console.error(`[api/docs] failed to render ${slug}.md`, error); + return new Response('Internal error', { status: 500 }); } - - return new Response(body, { - status: 200, - headers: { - 'Content-Type': 'text/markdown; charset=utf-8', - 'Cache-Control': DOCS_CACHE_CONTROL, - 'ETag': etag, - 'X-Content-Hash': contentHash, - }, - }); }; diff --git a/src/pages/api/docs/manifest.json.ts b/src/pages/api/docs/manifest.json.ts index 1446e10..2df1391 100644 --- a/src/pages/api/docs/manifest.json.ts +++ b/src/pages/api/docs/manifest.json.ts @@ -9,55 +9,65 @@ import { getRateLimitsConfig } from '../../../lib/rateLimits'; export const prerender = false; export const GET: APIRoute = async ({ request }) => { - const entries = (await getCollection('docs')).filter((e) => SAFE_SLUG.test(e.id)); - entries.sort((a, b) => { - if (a.data.order !== b.data.order) return a.data.order - b.data.order; - return a.id.localeCompare(b.id); - }); + try { + const entries = await getCollection('docs'); + for (const entry of entries) { + if (!SAFE_SLUG.test(entry.id)) { + throw new Error(`Doc id is not slug-safe: ${entry.id}`); + } + } + entries.sort((a, b) => { + if (a.data.order !== b.data.order) return a.data.order - b.data.order; + return a.id.localeCompare(b.id); + }); + + const rateLimits = getRateLimitsConfig(env); + const manifestEntries = await Promise.all( + entries.map(async (entry) => { + const text = await mdxToText(entry.body ?? '', rateLimits); + const contentHash = `sha256:${await sha256Hex(text)}`; + return { + slug: entry.id, + title: entry.data.title, + description: entry.data.description, + order: entry.data.order, + contentHash, + contentUrl: `/api/docs/${entry.id}.md`, + }; + }), + ); + + const versionSeed = JSON.stringify(manifestEntries.map((e) => [e.slug, e.contentHash])); + const version = `sha256:${await sha256Hex(versionSeed)}`; + const etag = quoteEtag(version); - const rateLimits = getRateLimitsConfig(env); - const manifestEntries = await Promise.all( - entries.map(async (entry) => { - const text = await mdxToText(entry.body ?? '', rateLimits); - const contentHash = `sha256:${await sha256Hex(text)}`; - return { - slug: entry.id, - title: entry.data.title, - description: entry.data.description, - order: entry.data.order, - contentHash, - contentUrl: `/api/docs/${entry.id}.md`, - }; - }), - ); + const ifNoneMatch = request.headers.get('if-none-match'); + if (ifNoneMatchMatches(ifNoneMatch, etag)) { + return new Response(null, { + status: 304, + headers: { + 'Cache-Control': DOCS_CACHE_CONTROL, + 'ETag': etag, + }, + }); + } - const versionSeed = JSON.stringify(manifestEntries.map((e) => [e.slug, e.contentHash])); - const version = `sha256:${await sha256Hex(versionSeed)}`; - const etag = quoteEtag(version); + const payload = { + version, + entries: manifestEntries, + }; + const body = JSON.stringify(payload, null, 2); - const ifNoneMatch = request.headers.get('if-none-match'); - if (ifNoneMatchMatches(ifNoneMatch, etag)) { - return new Response(null, { - status: 304, + return new Response(body, { + status: 200, headers: { + 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': DOCS_CACHE_CONTROL, 'ETag': etag, }, }); + } catch (error) { + console.error('[api/docs] failed to build manifest.json', error); + return new Response('Internal error', { status: 500 }); } - - const payload = { - version, - entries: manifestEntries, - }; - const body = JSON.stringify(payload, null, 2); - - return new Response(body, { - status: 200, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': DOCS_CACHE_CONTROL, - 'ETag': etag, - }, - }); };