Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
120 changes: 120 additions & 0 deletions apps/docs/scripts/gen-og.mjs
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html>
<html>
<head><meta charset="utf-8" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 630px; background: ${BG}; color: ${FG};
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
display: flex; flex-direction: column; justify-content: center;
padding: 0 82px; position: relative; overflow: hidden;
}
.dots {
position: absolute; inset: 0;
background-image: radial-gradient(rgba(255,255,255,0.055) 1px, transparent 1px);
background-size: 26px 26px;
}
.glow {
position: absolute; top: -220px; left: 50%; transform: translateX(-50%);
width: 1000px; height: 620px; filter: blur(60px); opacity: 0.55;
background: radial-gradient(ellipse at center, ${PRIMARY}44, transparent 68%);
}
.inner { position: relative; }
.brand { display: flex; align-items: center; gap: 15px; margin-bottom: 40px; }
.mark {
width: 62px; height: 62px; border-radius: 16px; background: ${PRIMARY};
color: #fff; font-size: 34px; font-weight: 800; line-height: 62px;
text-align: center; box-shadow: 0 10px 34px -8px ${PRIMARY};
}
.name { font-size: 35px; font-weight: 800; letter-spacing: -0.02em; }
.name span { color: ${PRIMARY_TEXT}; }
.ver {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 17px; font-weight: 600; color: ${MUTED};
border: 1px solid ${BORDER}; border-radius: 999px; padding: 6px 15px; margin-left: 6px;
}
h1 {
font-size: 67px; font-weight: 800; letter-spacing: -0.038em;
line-height: 1.08; max-width: 1010px;
}
h1 em { font-style: normal; color: ${PRIMARY_TEXT}; }
p { margin-top: 30px; font-size: 29px; line-height: 1.4; color: ${MUTED}; max-width: 900px; }
.pills { display: flex; gap: 12px; margin-top: 44px; }
.pill {
border: 1px solid ${BORDER}; border-radius: 999px; padding: 11px 22px;
font-size: 20px; font-weight: 600; color: ${FG}; background: rgba(255,255,255,0.03);
}
.rule { position: absolute; left: 0; right: 0; bottom: 0; height: 7px; background: ${PRIMARY}; }
</style>
</head>
<body>
<div class="dots"></div>
<div class="glow"></div>
<div class="inner">
<div class="brand">
<div class="mark">S</div>
<div class="name">Sve<span>·</span>UI</div>
<div class="ver">v${version}</div>
</div>
<h1>The Svelte&nbsp;5 UI component<br />library — <em>zero&nbsp;config.</em></h1>
<p>Fully styled, fully accessible components built on Bits&nbsp;UI.</p>
<div class="pills">
<div class="pill">${readyCount} components</div>
<div class="pill">No Tailwind</div>
<div class="pill">Accessible</div>
<div class="pill">CSS variables</div>
</div>
</div>
<div class="rule"></div>
</body>
</html>`;

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`);
3 changes: 3 additions & 0 deletions apps/docs/src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fcfcfb" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0d0d11" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
Expand Down
49 changes: 45 additions & 4 deletions apps/docs/src/lib/docs/DocPage.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/state';
import Seo from '$lib/seo/Seo.svelte';
import { breadcrumbSchema, techArticleSchema } from '$lib/seo/schema';

export interface TocEntry {
id: string;
Expand All @@ -13,6 +16,17 @@
toc?: TocEntry[];
/** Top-level breadcrumb link. Defaults to the Components section. */
crumb?: { href: string; label: string };
/**
* What this page documents. Component pages get a keyword-bearing title
* ("Svelte Button Component") because each one competes for its own
* long-tail query; guides keep the plain brand title.
*/
kind?: 'component' | 'guide';
/**
* Overrides the derived <title>. Guides use it because their page name
* alone ("Introduction") competes for nothing on its own.
*/
seoTitle?: string;
children: Snippet;
}

Expand All @@ -22,14 +36,41 @@
description,
toc = [],
crumb = { href: '/components', label: 'Components' },
kind = 'component',
seoTitle,
children
}: Props = $props();

let isComponent = $derived(kind === 'component');

let title = $derived(
seoTitle ?? (isComponent ? `Svelte ${name} Component — Sve·UI` : `${name} — Sve·UI`)
);

// The registry blurb is a UI lede, not a search snippet — too short to earn a
// click on its own. Component pages extend it with the terms people actually
// type; guides already write full sentences and are left alone.
let seoDescription = $derived(
isComponent
? `${description} A styled, accessible Svelte 5 ${name} component from Sve·UI — no Tailwind, no config, themeable with CSS variables.`
: description
);

let jsonLd = $derived([
breadcrumbSchema([
{ name: 'Home', path: '/' },
{ name: crumb.label, path: crumb.href },
{ name, path: page.url.pathname }
]),
techArticleSchema({
name: title,
description: seoDescription,
path: page.url.pathname
})
]);
</script>

<svelte:head>
<title>{name} — Sve·UI</title>
<meta name="description" content={description} />
</svelte:head>
<Seo {title} description={seoDescription} type="article" {jsonLd} />

<div class="docpage">
<article class="docpage__main">
Expand Down
101 changes: 101 additions & 0 deletions apps/docs/src/lib/seo/Seo.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<script lang="ts">
import { page } from '$app/state';
import {
SITE_NAME,
SITE_DESCRIPTION,
OG_IMAGE,
OG_IMAGE_WIDTH,
OG_IMAGE_HEIGHT,
OG_IMAGE_ALT,
absolute
} from './site';

interface Props {
/** Full <title> text, already including the brand suffix. */
title: string;
description?: string;
/**
* Canonical path. Defaults to the path actually being rendered, which is
* exact under prerendering and avoids hand-written paths going stale.
*/
path?: string;
/** og:type — 'website' for landing pages, 'article' for docs content. */
type?: 'website' | 'article';
/** JSON-LD graph nodes appended to this page's structured data. */
jsonLd?: Record<string, unknown>[];
}

let {
title,
description = SITE_DESCRIPTION,
path = page.url.pathname,
type = 'website',
jsonLd = []
}: Props = $props();

let canonical = $derived(absolute(path));
let image = $derived(absolute(OG_IMAGE));

/**
* Svelte cannot render a script tag from markup, so structured data goes in
* through {@html}. Every `<` is escaped to its JSON `\u003c` form: that is what
* stops a stray closing script tag inside a description from ending the block
* early and injecting markup. The closing tag below is escaped for the same
* reason — an unescaped one would terminate THIS component's script element.
*/
let structuredData = $derived.by(() => {
if (!jsonLd.length) return '';

// A @graph declares @context once for the whole document, so the per-node
// copies the builders emit are stripped here rather than repeated.
const stripContext = (node: Record<string, unknown>) => {
const copy = { ...node };
delete copy['@context'];
return copy;
};

const payload =
jsonLd.length === 1
? jsonLd[0]
: { '@context': 'https://schema.org', '@graph': jsonLd.map(stripContext) };

const json = JSON.stringify(payload).replace(/</g, '\\u003c');

// The escape IS required: an unescaped closing script tag in this template
// literal would terminate the component's own <script> element at parse
// time. eslint only sees the JS string, where the escape looks redundant.
// eslint-disable-next-line no-useless-escape
return `<script type="application/ld+json">${json}<\/script>`;
});
</script>

<svelte:head>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />

<meta property="og:type" content={type} />
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:locale" content="en_US" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={image} />
<meta property="og:image:width" content={String(OG_IMAGE_WIDTH)} />
<meta property="og:image:height" content={String(OG_IMAGE_HEIGHT)} />
<meta property="og:image:alt" content={OG_IMAGE_ALT} />

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
<meta name="twitter:image:alt" content={OG_IMAGE_ALT} />

{#if structuredData}
<!-- Safe by construction: the payload is JSON.stringify output with every
`<` escaped above, so no caller-supplied string can emit markup. This is
the only way to render a <script> tag from Svelte markup. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html structuredData}
{/if}
</svelte:head>
Loading
Loading