From d28dc9628303aaf1eff6f85f5db5eafbc4722ab9 Mon Sep 17 00:00:00 2001 From: borjaperfra Date: Thu, 15 Oct 2026 18:00:22 +0200 Subject: [PATCH] feat(docs): badge "Built with NaN" para los READMEs de la comunidad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tres SVG en public/badge (tarjeta oscura, clara y compacta) servidos desde nan.builders/badge/, y la guía /docs/built-with-nan (EN y ES) con los snippets: HTML con para que cada README muestre la tarjeta de su tema, y Markdown para el compacto. Los enlaces llevan ?ref=built-with-nan. Los SVG salen de scripts/gen-badges.mjs: texto a trazos con fontkitten (declarado como devDependency) y el trazo "NaN" del wordmark, para que se vean igual dentro de un de GitHub, donde no cargan fuentes. Las vistas previas van como imágenes markdown y no como HTML, porque mdxToText rechaza bloques HTML crudos y tumbaría /api/docs/manifest.json. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 1 + package.json | 1 + public/badge/built-with-nan-compact.svg | 6 ++ public/badge/built-with-nan-dark.svg | 7 ++ public/badge/built-with-nan-light.svg | 7 ++ scripts/gen-badges.mjs | 104 +++++++++++++++++++++ src/content/docs-es/built-with-nan.mdx | 47 ++++++++++ src/content/docs/built-with-nan.mdx | 47 ++++++++++ src/styles/global.css | 13 +++ src/tests/layouts/builtWithNanDocs.test.ts | 100 ++++++++++++++++++++ 10 files changed, 333 insertions(+) create mode 100644 public/badge/built-with-nan-compact.svg create mode 100644 public/badge/built-with-nan-dark.svg create mode 100644 public/badge/built-with-nan-light.svg create mode 100644 scripts/gen-badges.mjs create mode 100644 src/content/docs-es/built-with-nan.mdx create mode 100644 src/content/docs/built-with-nan.mdx create mode 100644 src/tests/layouts/builtWithNanDocs.test.ts diff --git a/package-lock.json b/package-lock.json index 8c564f05..2dd723cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@astrojs/check": "^0.9.8", "@cloudflare/workers-types": "^4.20260317.1", "ajv": "^8.20.0", + "fontkitten": "^1.0.3", "playwright": "^1.59.1", "preact-render-to-string": "^6.6.7", "typescript": "^5.9.3", diff --git a/package.json b/package.json index 2c427c21..9e179084 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@astrojs/check": "^0.9.8", "@cloudflare/workers-types": "^4.20260317.1", "ajv": "^8.20.0", + "fontkitten": "^1.0.3", "playwright": "^1.59.1", "preact-render-to-string": "^6.6.7", "typescript": "^5.9.3", diff --git a/public/badge/built-with-nan-compact.svg b/public/badge/built-with-nan-compact.svg new file mode 100644 index 00000000..a5d9ceca --- /dev/null +++ b/public/badge/built-with-nan-compact.svg @@ -0,0 +1,6 @@ +Built with NaN + + + + + diff --git a/public/badge/built-with-nan-dark.svg b/public/badge/built-with-nan-dark.svg new file mode 100644 index 00000000..f3f4bd05 --- /dev/null +++ b/public/badge/built-with-nan-dark.svg @@ -0,0 +1,7 @@ +Built with NaN + + + + + + diff --git a/public/badge/built-with-nan-light.svg b/public/badge/built-with-nan-light.svg new file mode 100644 index 00000000..825ce9cd --- /dev/null +++ b/public/badge/built-with-nan-light.svg @@ -0,0 +1,7 @@ +Built with NaN + + + + + + diff --git a/scripts/gen-badges.mjs b/scripts/gen-badges.mjs new file mode 100644 index 00000000..b0230f07 --- /dev/null +++ b/scripts/gen-badges.mjs @@ -0,0 +1,104 @@ +/** + * Genera los badges "Built with NaN" que la comunidad pega en sus READMEs. + * + * node scripts/gen-badges.mjs + * + * Salida: public/badge/built-with-nan-{dark,light,compact}.svg, servidos en + * https://nan.builders/badge/… (ver /docs/built-with-nan). + * + * Los SVG son autocontenidos: GitHub los muestra dentro de un , donde no + * se cargan fuentes externas, así que "BUILT WITH" va convertido a trazos con + * fontkitten (JetBrains Mono de @fontsource) y "NaN" es el primer trazo del + * wordmark vectorial de public/brand. El resto del wordmark es ".builders". + * + * Las URLs publicadas son contrato: hay READMEs ajenos apuntando a ellas y + * buscarlas en GitHub es como encontramos los proyectos. Se puede cambiar el + * diseño, no el nombre de los ficheros. + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { create } from 'fontkitten'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +// tokens (src/styles/nan-system.css) +const BG = '#0B0B0C'; +const SNOW = '#EFEEEB'; +const SNOW_INK = '#101010'; +const VIOLET = '#7D39EB'; +const VIOLET_2 = '#9B6BF0'; + +// isotipo (public/brand/nan-isotipo-color.svg, viewBox 100x100) +const HEX = 'M54.33 11.5 L81.17 27 Q85.5 29.5 85.5 34.5 L85.5 65.5 Q85.5 70.5 81.17 73 L54.33 88.5 Q50 91 45.67 88.5 L18.83 73 Q14.5 70.5 14.5 65.5 L14.5 34.5 Q14.5 29.5 18.83 27 L45.67 11.5 Q50 9 54.33 11.5 Z'; +const INF = 'M24 50 C24 42.5 29.5 38 36 38 C43 38 46.5 43.5 50 50 C53.5 56.5 57 62 64 62 C70.5 62 76 57.5 76 50 C76 42.5 70.5 38 64 38 C57 38 53.5 43.5 50 50 C46.5 56.5 43 62 36 62 C29.5 62 24 57.5 24 50 Z'; + +const iso = (x, y, size, color, sw = 4.5) => + ``; + +/** Texto a trazos. x,y = origen de la línea base; size en px; tracking en em. */ +function text(font, str, x, y, size, fill, tracking = 0) { + const s = size / font.unitsPerEm; + let cx = 0; + const parts = []; + for (const g of font.glyphsForString(str)) { + const d = g.path.toSVG(); + if (d) parts.push(``); + cx += g.advanceWidth + tracking * font.unitsPerEm; + } + return { svg: `${parts.join('')}`, width: (cx - tracking * font.unitsPerEm) * s }; +} + +const svg = (w, h, body) => + `Built with NaN${body}\n`; + +/** Devuelve { 'built-with-nan-dark.svg': ' ``; + const nanH = (w) => (w * 32) / 84; + + const files = {}; + + // Principal: tarjeta en el lenguaje del sitio, en oscuro y en claro. + for (const [name, bg, line, label, ink] of [ + ['built-with-nan-dark.svg', BG, 'rgba(255,255,255,0.16)', '#9A9A9E', '#FFFFFF'], + ['built-with-nan-light.svg', SNOW, 'rgba(16,16,16,0.16)', 'rgba(16,16,16,0.62)', SNOW_INK], + ]) { + const W = 266, H = 104; + const lbl = text(mono, 'BUILT WITH', 108, 32, 11, label, 0.2); + files[name] = svg(W, H, ` + +${iso(18, 20, 64, VIOLET)} + +${lbl.svg} +${nan(108, 42, 124, ink)} +`); + } + + // Compacto: formato shields.io (alto 28) para la fila de badges. + { + const lbl = text(mono, 'BUILT WITH', 32, 18.5, 10, '#FFFFFF', 0.12); + const L = Math.ceil(32 + lbl.width + 10), R = 70, H = 28; + files['built-with-nan-compact.svg'] = svg(L + R, H, ` + + +${iso(7, 4, 20, VIOLET_2, 6)} +${lbl.svg} +${nan(L + 11, (H - nanH(48)) / 2, 48, '#FFFFFF')}`); + } + + return files; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const out = resolve(root, 'public/badge'); + mkdirSync(out, { recursive: true }); + for (const [name, body] of Object.entries(renderBadges())) { + writeFileSync(resolve(out, name), body); + console.log(`public/badge/${name} ${(body.length / 1024).toFixed(1)} KB`); + } +} diff --git a/src/content/docs-es/built-with-nan.mdx b/src/content/docs-es/built-with-nan.mdx new file mode 100644 index 00000000..3ac2fd61 --- /dev/null +++ b/src/content/docs-es/built-with-nan.mdx @@ -0,0 +1,47 @@ +--- +title: Built with NaN +description: Añade el badge Built with NaN al README de tu proyecto. +order: 27 +group: Guías +--- + +import Callout from '../../components/docs/Callout.astro'; + +# Built with NaN. + +¿Has sacado algo que funciona con NaN? Pon el badge en tu README. Enlaza a [nan.builders](https://nan.builders) y es como encontramos los proyectos de la comunidad para destacarlos. + +![Built with NaN, versión oscura](/badge/built-with-nan-dark.svg) ![Built with NaN, versión clara](/badge/built-with-nan-light.svg) ![Built with NaN, versión compacta](/badge/built-with-nan-compact.svg) + +## Añádelo a tu README + +### Tarjeta + +Pega este HTML. GitHub muestra la tarjeta oscura a quien lee con tema oscuro y la clara a quien lee con tema claro. + +```html + + + + Built with NaN + + +``` + +### Compacto + +Para ponerlo en la fila con tus otros badges (build, licencia), usa el compacto. Funciona con los dos temas. + +```markdown +[![Built with NaN](https://nan.builders/badge/built-with-nan-compact.svg)](https://nan.builders/?ref=built-with-nan) +``` + + +Enlaza la imagen desde `nan.builders/badge/` en vez de descargarla a tu repo. Encontramos los proyectos que llevan el badge buscando esa dirección en GitHub, y una copia alojada en otro sitio no aparece. Además, te llegan los cambios de diseño sin tocar tu README. + + +## Cómo usar el badge + +- Úsalo en proyectos hechos con NaN o que llamen a sus modelos. +- No cambies los colores, el logo ni las proporciones. Si necesitas otro tamaño, cambia `width`. +- El badge dice que tu proyecto usa NaN. No significa que NaN lo revise, lo avale ni sea socio del proyecto. diff --git a/src/content/docs/built-with-nan.mdx b/src/content/docs/built-with-nan.mdx new file mode 100644 index 00000000..e5dd30b1 --- /dev/null +++ b/src/content/docs/built-with-nan.mdx @@ -0,0 +1,47 @@ +--- +title: Built with NaN +description: Add the Built with NaN badge to your project's README. +order: 27 +group: Guides +--- + +import Callout from '../../components/docs/Callout.astro'; + +# Built with NaN. + +Shipped something that runs on NaN? Put the badge in your README. It links back to [nan.builders](https://nan.builders), and it is how we find community projects to feature. + +![Built with NaN, dark version](/badge/built-with-nan-dark.svg) ![Built with NaN, light version](/badge/built-with-nan-light.svg) ![Built with NaN, compact version](/badge/built-with-nan-compact.svg) + +## Add it to your README + +### Card + +Paste this HTML. GitHub shows the dark card to readers on a dark theme and the light one to readers on a light theme. + +```html + + + + Built with NaN + + +``` + +### Compact + +To put it in a row with your other badges (build, license), use the compact one. It works on either theme. + +```markdown +[![Built with NaN](https://nan.builders/badge/built-with-nan-compact.svg)](https://nan.builders/?ref=built-with-nan) +``` + + +Link the image from `nan.builders/badge/` instead of downloading it into your repo. We find the projects that carry the badge by searching GitHub for that address, and a copy hosted elsewhere does not show up. You also get design updates without touching your README. + + +## Using the badge + +- Use it on projects that are built with NaN or call its models. +- Don't change the colors, the logo or the proportions. If you need another size, change `width`. +- The badge says your project uses NaN. It does not mean NaN reviews, endorses or partners with your project. diff --git a/src/styles/global.css b/src/styles/global.css index 01199959..8e3a2d87 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -339,3 +339,16 @@ border: 1px solid rgb(38 38 38 / 0.6); border-radius: 0.75rem; } + +/* Los badges de /docs/built-with-nan se ven a su tamaño, como en un README, + no estirados al ancho ni enmarcados como una captura. Van como imágenes + markdown (no HTML) porque mdxToText no admite bloques HTML crudos. */ +.docs-content img[src^="/badge/"] { + display: inline-block; + vertical-align: middle; + width: auto; + max-width: 100%; + margin: 0.5rem 1.25rem 0.5rem 0; + border: 0; + border-radius: 0; +} diff --git a/src/tests/layouts/builtWithNanDocs.test.ts b/src/tests/layouts/builtWithNanDocs.test.ts new file mode 100644 index 00000000..92c8f562 --- /dev/null +++ b/src/tests/layouts/builtWithNanDocs.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'vitest'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { parseFrontmatter } from '@astrojs/markdown-remark'; + +/** + * The "Built with NaN" badge: SVGs in public/badge, generated by + * scripts/gen-badges.mjs, and the /docs/built-with-nan page with the snippets + * people paste into their READMEs. + * + * Owner's ask (2026-10-15, community thread): something like Gentle-AI's + * "Built with Gentle-AI", with a URL we can search GitHub for to find the + * projects that carry it. Designs B (card, dark and light) and C (compact) + * were picked from the proposals. + * + * The badge URLs are a contract: other people's READMEs point at them, so a + * renamed file breaks every badge out there without any error on our side. + */ +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '../../..'); +const badgeDir = resolve(root, 'public/badge'); +const read = (p: string) => readFileSync(p, 'utf-8'); + +const BADGES = ['built-with-nan-dark.svg', 'built-with-nan-light.svg', 'built-with-nan-compact.svg']; + +const pages = { + en: { file: 'src/content/docs/built-with-nan.mdx', group: 'Guides' }, + es: { file: 'src/content/docs-es/built-with-nan.mdx', group: 'Guías' }, +} as const; + +const body = (raw: string) => raw.replace(/^---[\s\S]*?\n---\n/, ''); +const codeBlocks = (b: string) => [...b.matchAll(/```[\s\S]*?```/g)].map((m) => m[0]); + +describe('the badge files', () => { + test('are exactly the published set', () => { + expect(readdirSync(badgeDir).sort()).toEqual([...BADGES].sort()); + }); + + /** + * Hand-editing a committed SVG would be undone by the next run of the + * script, so the two must agree. + */ + test('match what scripts/gen-badges.mjs generates', async () => { + const { renderBadges } = await import(pathToFileURL(resolve(root, 'scripts/gen-badges.mjs')).href); + const generated = renderBadges() as Record; + expect(Object.keys(generated).sort()).toEqual([...BADGES].sort()); + for (const name of BADGES) expect(read(resolve(badgeDir, name)), name).toBe(generated[name]); + }); + + /** + * GitHub renders README images inside an , where an SVG cannot load + * fonts or anything else from outside. Text has to be paths, or the badge + * shows in whatever fallback font the reader's browser picks. + */ + test.each(BADGES)('%s is self-contained', (name) => { + const svg = read(resolve(badgeDir, name)); + expect(svg).not.toMatch(/Built with NaN'); + expect(svg.length).toBeLessThan(16 * 1024); + }); +}); + +describe.each(Object.entries(pages))('the %s page', (_lang, page) => { + const raw = read(resolve(root, page.file)); + const fm = parseFrontmatter(raw).frontmatter as Record; + const snippets = codeBlocks(body(raw)).join('\n'); + + test('sits in the guides group', () => { + expect(fm.group).toBe(page.group); + expect(fm.title).toBe('Built with NaN'); + }); + + test('offers the card with both themes and the compact badge', () => { + expect(snippets).toContain('srcset="https://nan.builders/badge/built-with-nan-light.svg"'); + expect(snippets).toContain('src="https://nan.builders/badge/built-with-nan-dark.svg"'); + expect(snippets).toContain('(https://nan.builders/badge/built-with-nan-compact.svg)'); + }); + + test('only points at badge files that exist', () => { + const urls = [...raw.matchAll(/(?:https:\/\/nan\.builders)?\/badge\/([\w.-]+)/g)].map((m) => m[1]); + expect(urls.length).toBeGreaterThan(0); + for (const f of urls) expect(existsSync(resolve(badgeDir, f)), f).toBe(true); + }); + + /** The ref is how a click from a README shows up in our analytics. */ + test('links every snippet back with the ref', () => { + const links = [...snippets.matchAll(/(?:href="|\]\()(https:\/\/nan\.builders\/[^")]*)/g)] + .map((m) => m[1]) + .filter((u) => !u.includes('/badge/')); + expect(links.length).toBe(2); + for (const l of links) expect(l).toBe('https://nan.builders/?ref=built-with-nan'); + }); +}); + +test('both languages publish the same snippets', () => { + const [en, es] = [pages.en, pages.es].map((p) => codeBlocks(body(read(resolve(root, p.file))))); + expect(es).toEqual(en); +});