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 1/5] 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
Disco20 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 2/5] 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 3/5] 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 4/5] =?UTF-8?q?feat(docs):=20canonical=20MDX=E2=86=92text?= =?UTF-8?q?=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 5/5] 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",