diff --git a/apps/docs/package.json b/apps/docs/package.json index bbc5809..b823acb 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -12,6 +12,7 @@ "lint": "eslint .", "format": "prettier --write .", "gen:props": "node scripts/gen-props.mjs", + "gen:og": "node scripts/gen-og.mjs", "gen:props:check": "node scripts/gen-props.mjs && git diff --exit-code -- src/lib/docs/generated/props.json", "check:render": "node scripts/check-render.mjs", "check:render:update": "node scripts/check-render.mjs --update", diff --git a/apps/docs/scripts/gen-og.mjs b/apps/docs/scripts/gen-og.mjs new file mode 100644 index 0000000..26118a6 --- /dev/null +++ b/apps/docs/scripts/gen-og.mjs @@ -0,0 +1,120 @@ +/** + * Generates static/og.png — the 1200×630 social card referenced by og:image. + * + * Rendered with the Playwright Chromium already installed for the visual suite, + * so there is no extra dependency. It must be a raster image: X, Slack, LinkedIn + * and Facebook all refuse to render an SVG og:image, which is why the brand SVG + * cannot simply be pointed at. + * + * Run with `pnpm gen:og` after changing the tagline, the count or the branding. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { chromium } from '@playwright/test'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '..'); + +const version = JSON.parse( + readFileSync(resolve(root, '../../packages/sve-ui/package.json'), 'utf8') +).version; + +// Counted from the registry rather than hardcoded so the card cannot claim a +// number the site itself contradicts. Comments are stripped first: the file's +// own header documents `ready: true` in prose and would inflate the count by one. +const registry = readFileSync(resolve(root, 'src/lib/docs/registry.ts'), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); +const readyCount = (registry.match(/ready:\s*true/g) ?? []).length; + +const BG = '#0d0d11'; +const FG = '#f4f4f6'; +const MUTED = '#9a9aa6'; +const PRIMARY = '#f56565'; +const PRIMARY_TEXT = '#ff8a8a'; +const BORDER = '#26262f'; + +const html = ` + + + + + +
+
+
+
+
S
+
Sve·UI
+
v${version}
+
+

The Svelte 5 UI component
library — zero config.

+

Fully styled, fully accessible components built on Bits UI.

+
+
${readyCount} components
+
No Tailwind
+
Accessible
+
CSS variables
+
+
+
+ +`; + +const browser = await chromium.launch(); +const page = await browser.newPage({ + viewport: { width: 1200, height: 630 }, + deviceScaleFactor: 1 +}); +await page.setContent(html, { waitUntil: 'load' }); +const buffer = await page.screenshot({ type: 'png' }); +await browser.close(); + +const out = resolve(root, 'static/og.png'); +writeFileSync(out, buffer); +console.log(`og.png written — ${readyCount} components, v${version}, ${buffer.length} bytes`); diff --git a/apps/docs/src/app.html b/apps/docs/src/app.html index 686f345..078b68a 100644 --- a/apps/docs/src/app.html +++ b/apps/docs/src/app.html @@ -3,7 +3,10 @@ + + + %sveltekit.head% diff --git a/apps/docs/src/lib/docs/DocPage.svelte b/apps/docs/src/lib/docs/DocPage.svelte index 29f8777..6020137 100644 --- a/apps/docs/src/lib/docs/DocPage.svelte +++ b/apps/docs/src/lib/docs/DocPage.svelte @@ -1,5 +1,8 @@ - - {name} — Sve·UI - - +
diff --git a/apps/docs/src/lib/seo/Seo.svelte b/apps/docs/src/lib/seo/Seo.svelte new file mode 100644 index 0000000..326c029 --- /dev/null +++ b/apps/docs/src/lib/seo/Seo.svelte @@ -0,0 +1,101 @@ + + + + {title} + + + + + + + + + + + + + + + + + + + + + {#if structuredData} + + + {@html structuredData} + {/if} + diff --git a/apps/docs/src/lib/seo/schema.ts b/apps/docs/src/lib/seo/schema.ts new file mode 100644 index 0000000..856ce82 --- /dev/null +++ b/apps/docs/src/lib/seo/schema.ts @@ -0,0 +1,114 @@ +/** + * Schema.org structured data builders. + * + * These describe the library to search engines in a machine-readable way, which + * is what makes a result eligible for rich treatment (breadcrumb trails, sitelinks + * search, software app cards) instead of a plain blue link. + */ + +import pkg from 'sve-ui/package.json'; +import { SITE_URL, SITE_NAME, SITE_DESCRIPTION, REPO_URL, absolute, OG_IMAGE } from './site'; + +const CONTEXT = 'https://schema.org'; + +/** Identifies the site itself and enables the sitelinks search box. */ +export function websiteSchema(): Record { + return { + '@context': CONTEXT, + '@type': 'WebSite', + '@id': `${SITE_URL}/#website`, + name: SITE_NAME, + alternateName: 'sve-ui', + url: SITE_URL, + description: SITE_DESCRIPTION, + inLanguage: 'en' + }; +} + +/** + * The library as a product. `offers` at price 0 is not decoration: Google's + * software-app rich result requires either an offer or an aggregate rating, and + * a free MIT package genuinely has the former. + */ +export function softwareSchema(): Record { + return { + '@context': CONTEXT, + '@type': 'SoftwareApplication', + '@id': `${SITE_URL}/#software`, + name: 'sve-ui', + alternateName: SITE_NAME, + applicationCategory: 'DeveloperApplication', + applicationSubCategory: 'UI Component Library', + description: SITE_DESCRIPTION, + url: SITE_URL, + image: absolute(OG_IMAGE), + softwareVersion: pkg.version, + license: 'https://opensource.org/licenses/MIT', + codeRepository: REPO_URL, + programmingLanguage: ['Svelte', 'TypeScript'], + operatingSystem: 'Any', + keywords: pkg.keywords?.join(', '), + author: { + '@type': 'Person', + name: 'Rodrigo Abregu', + url: 'https://rodriab.io/' + }, + offers: { + '@type': 'Offer', + price: '0', + priceCurrency: 'USD' + } + }; +} + +/** A page documenting one component, tied back to the library it belongs to. */ +export function techArticleSchema(input: { + name: string; + description: string; + path: string; +}): Record { + return { + '@context': CONTEXT, + '@type': 'TechArticle', + headline: input.name, + description: input.description, + url: absolute(input.path), + image: absolute(OG_IMAGE), + inLanguage: 'en', + isPartOf: { '@id': `${SITE_URL}/#website` }, + about: { '@id': `${SITE_URL}/#software` }, + author: { '@type': 'Person', name: 'Rodrigo Abregu', url: 'https://rodriab.io/' } + }; +} + +/** Renders the crumb trail Google shows in place of a raw URL. */ +export function breadcrumbSchema( + crumbs: { name: string; path: string }[] +): Record { + return { + '@context': CONTEXT, + '@type': 'BreadcrumbList', + itemListElement: crumbs.map((c, i) => ({ + '@type': 'ListItem', + position: i + 1, + name: c.name, + item: absolute(c.path) + })) + }; +} + +/** The component index as an enumerated list — one entry per documented component. */ +export function itemListSchema(items: { name: string; path: string }[]): Record { + return { + '@context': CONTEXT, + '@type': 'ItemList', + name: 'Svelte UI Components', + numberOfItems: items.length, + itemListElement: items.map((it, i) => ({ + '@type': 'ListItem', + position: i + 1, + name: it.name, + url: absolute(it.path) + })) + }; +} diff --git a/apps/docs/src/lib/seo/site.ts b/apps/docs/src/lib/seo/site.ts new file mode 100644 index 0000000..0a412b0 --- /dev/null +++ b/apps/docs/src/lib/seo/site.ts @@ -0,0 +1,36 @@ +/** + * Canonical site identity — the single source of truth for every absolute URL + * the site emits: canonical tags, Open Graph, structured data and the sitemap. + * + * Absolute URLs are unavoidable in SEO markup (a canonical or an og:image MUST + * be absolute), so they are derived here instead of being spelled out per page. + * Change the domain once and nothing is left pointing at the old one. + */ + +export const SITE_URL = 'https://sveui.org'; + +export const SITE_NAME = 'Sve·UI'; + +/** Primary keyword phrase. Reused across titles, descriptions and JSON-LD. */ +export const SITE_TAGLINE = 'Svelte UI Component Library'; + +export const SITE_DESCRIPTION = + 'Sve·UI is a Svelte 5 UI component library: fully styled, fully accessible components built on Bits UI. No Tailwind and no config in your project — install, import, and theme with CSS variables.'; + +/** 1200×630 social card. Regenerate with `pnpm gen:og`. */ +export const OG_IMAGE = '/og.png'; +export const OG_IMAGE_WIDTH = 1200; +export const OG_IMAGE_HEIGHT = 630; +export const OG_IMAGE_ALT = 'Sve·UI — styled, accessible Svelte 5 UI components. Zero config.'; + +export const REPO_URL = 'https://github.com/rodriabregu/sve-ui'; +export const NPM_URL = 'https://www.npmjs.com/package/sve-ui'; + +/** + * Resolve a site-relative path to its absolute canonical URL. + * `/` stays `https://sveui.org/`; every other path keeps SvelteKit's default + * `trailingSlash: 'never'` shape so canonicals match the URLs actually served. + */ +export function absolute(path: string): string { + return new URL(path, SITE_URL).href; +} diff --git a/apps/docs/src/routes/+page.svelte b/apps/docs/src/routes/+page.svelte index a297d86..104e9fa 100644 --- a/apps/docs/src/routes/+page.svelte +++ b/apps/docs/src/routes/+page.svelte @@ -1,5 +1,7 @@ - - Sve·UI — Styled, accessible Svelte 5 components. Zero config. - - +
- Styled, accessible
Svelte 5 components —
Svelte 5 UI components —
zero config. @@ -131,7 +131,8 @@ class="mt-6 max-w-[480px]" style="font-size: 17.5px; line-height: 1.55; color: var(--doc-fg-muted);" > - Fully styled, fully accessible components built on Bits UI. + The Svelte UI component library with fully styled, fully accessible components built on + Bits UI. No Tailwind. No config in your project — import { componentGroups, readyComponents, totalComponents } from '$lib/docs/registry'; + import Seo from '$lib/seo/Seo.svelte'; + import { breadcrumbSchema, itemListSchema } from '$lib/seo/schema'; const comingSoon = totalComponents - readyComponents; + + // The index is the hub every component page links back to, so it gets the + // broad category term while the leaf pages take the specific ones. + const listed = componentGroups.flatMap((g) => + g.items + .filter((it) => it.ready) + .map((it) => ({ name: it.name, path: `/components/${it.slug}` })) + ); - - Components — Sve·UI - - +
{readyComponents} components · {comingSoon} on the way -

Components

+

Svelte UI Components

Every component is fully styled and accessible out of the box — built on Bits UI, themed with CSS variables, and ready to drop in. Pick one to see live previews, code and props. Items diff --git a/apps/docs/src/routes/docs/+page.svelte b/apps/docs/src/routes/docs/+page.svelte index 9190789..bf4bdc7 100644 --- a/apps/docs/src/routes/docs/+page.svelte +++ b/apps/docs/src/routes/docs/+page.svelte @@ -17,6 +17,8 @@ description="A library of ready-made, fully styled and accessible Svelte 5 components — no Tailwind and no config in your project." {toc} {crumb} + kind="guide" + seoTitle="Introduction to Sve·UI — Svelte 5 UI Component Library" >

What is sve-ui

diff --git a/apps/docs/src/routes/docs/ai-agents/+page.svelte b/apps/docs/src/routes/docs/ai-agents/+page.svelte index 6f2a684..f878e8c 100644 --- a/apps/docs/src/routes/docs/ai-agents/+page.svelte +++ b/apps/docs/src/routes/docs/ai-agents/+page.svelte @@ -32,6 +32,8 @@ cp -r node_modules/sve-ui/skills/sve-ui-usage .claude/skills/ description="A packaged skill so AI agents (Claude Code, Cursor, …) generate correct sve-ui code instead of hallucinating props." {toc} {crumb} + kind="guide" + seoTitle="Sve·UI for AI Agents — Svelte Skill for Claude Code & Cursor" >

Why a skill

diff --git a/apps/docs/src/routes/docs/installation/+page.svelte b/apps/docs/src/routes/docs/installation/+page.svelte index 9ce1050..af3d57e 100644 --- a/apps/docs/src/routes/docs/installation/+page.svelte +++ b/apps/docs/src/routes/docs/installation/+page.svelte @@ -44,6 +44,8 @@ import 'sve-ui/theme.css';`; description="Add the package, import the stylesheet once, and start using components. No Tailwind, no config." {toc} {crumb} + kind="guide" + seoTitle="Install Sve·UI — Svelte UI Component Library Setup" >

Install

diff --git a/apps/docs/src/routes/docs/theming/+page.svelte b/apps/docs/src/routes/docs/theming/+page.svelte index 055d54e..67a4b5d 100644 --- a/apps/docs/src/routes/docs/theming/+page.svelte +++ b/apps/docs/src/routes/docs/theming/+page.svelte @@ -44,6 +44,8 @@ $effect(() => { description="Every color, radius and space is a --sve-* CSS variable. Override them anywhere — no rebuild, no config." {toc} {crumb} + kind="guide" + seoTitle="Theming Svelte Components with CSS Variables — Sve·UI" >

The token model

diff --git a/apps/docs/src/routes/playground/+page.svelte b/apps/docs/src/routes/playground/+page.svelte index c84d28f..00951b0 100644 --- a/apps/docs/src/routes/playground/+page.svelte +++ b/apps/docs/src/routes/playground/+page.svelte @@ -1,4 +1,5 @@ -Playground — Sve·UI + diff --git a/apps/docs/src/routes/sitemap.xml/+server.ts b/apps/docs/src/routes/sitemap.xml/+server.ts new file mode 100644 index 0000000..9d3f492 --- /dev/null +++ b/apps/docs/src/routes/sitemap.xml/+server.ts @@ -0,0 +1,44 @@ +import { componentGroups } from '$lib/docs/registry'; +import { guideGroups } from '$lib/docs/guides'; +import { SITE_URL } from '$lib/seo/site'; + +/** + * /sitemap.xml — generated from the component registry and guide nav, exactly + * like /llms.txt, so a new component page is discoverable by search engines the + * moment it is registered. A hand-maintained list would drift on the first PR. + */ +export const prerender = true; + +/** Only pages that actually render. `ready: false` components are 404s. */ +function urls(): string[] { + const components = componentGroups.flatMap((g) => + g.items.filter((it) => it.ready).map((it) => `/components/${it.slug}`) + ); + const guides = guideGroups.flatMap((g) => g.items.map((it) => it.href)); + + return ['/', '/components', ...guides, ...components, '/playground']; +} + +export function GET() { + // Prerendered, so this is the build timestamp — which for this site is the + // deploy that followed the last content or release change. + const lastmod = new Date().toISOString(); + + const body = ` + +${urls() + .map( + (path) => + `\t\n\t\t${SITE_URL}${path === '/' ? '/' : path}\n\t\t${lastmod}\n\t` + ) + .join('\n')} + +`; + + return new Response(body, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Cache-Control': 'public, max-age=0, s-maxage=3600' + } + }); +} diff --git a/apps/docs/static/og.png b/apps/docs/static/og.png new file mode 100644 index 0000000..373558d Binary files /dev/null and b/apps/docs/static/og.png differ diff --git a/apps/docs/static/robots.txt b/apps/docs/static/robots.txt index e9e57dc..06084f3 100644 --- a/apps/docs/static/robots.txt +++ b/apps/docs/static/robots.txt @@ -1,3 +1,5 @@ # https://www.robotstxt.org/robotstxt.html User-agent: * -Disallow: +Allow: / + +Sitemap: https://sveui.org/sitemap.xml