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
309 changes: 186 additions & 123 deletions packages/docs/CLAUDE.md

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions packages/docs/bin/check-langs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
* Six failure modes, all of them silent otherwise:
*
* 1. **A missing label key** renders a blank sidebar entry — a row you can click but not
* read. VitePress reports nothing.
* 2. **A missing page** in a mirrored tree is a sidebar link to a 404. VitePress's own
* dead-link check catches it during `build`, but only after a full compile; catching it
* here is seconds instead of minutes, and it also runs when the page exists in a locale
* but nowhere else (a stray file the other languages never got).
* read. Nothing else reports it.
* 2. **A missing page** in a mirrored tree is a sidebar link to a 404. `build/verify.ts`
* catches it, but only after a full build; catching it here is seconds instead of
* minutes, and it also runs when the page exists in one locale but nowhere else (a
* stray file the other languages never got).
* 3. **A stray label key** is dead weight that survives every rename of the structure.
* 4. **A translation that lost its shape** — a truncated file, a heading demoted from `###`
* to `##`, a code fence or `<ran-demo>` block dropped in the rewrite. The page still builds
Expand Down
11 changes: 6 additions & 5 deletions packages/docs/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ export const markdown = createMarkdown({
origin: ORIGIN,
langs: LANGS,
fences: {
// Same handoff the VitePress fence hook does today: the diagram source is
// URI-encoded because that is what `<r-mermaid>`'s `code` getter decodes.
// The diagram source is URI-encoded because that is what `<r-mermaid>`'s `code`
// getter decodes.
mermaid: (code) => `<r-mermaid code="${encodeURIComponent(code)}"></r-mermaid>\n`,
},
components: componentRenderers,
Expand All @@ -38,9 +38,10 @@ export const markdown = createMarkdown({
danger: ({ title, body }) =>
`<aside class="callout callout--danger"><p class="callout__label">${title || 'DANGER'}</p>${body}</aside>\n`,
/**
* VitePress's escape hatch for Vue template compilation — it stops `{{` being read
* as an interpolation. There is no Vue here, so there is nothing to escape and the
* marker is transparent. 25 pages carry it and none of them need a feature built.
* Inherited from VitePress, where it stopped `{{` being read as a Vue interpolation.
* There is no template compiler here, so there is nothing to escape and the marker is
* transparent — 25 pages still carry it and none of them need a feature built. Kept so
* those pages render rather than failing on an unknown container.
*/
'v-pre': ({ body }) => body,
/**
Expand Down
10 changes: 5 additions & 5 deletions packages/docs/build/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
* markup is in the server-rendered HTML, indexable and correct with no JavaScript, and
* the script only adds motion and interaction on top.
*
* Markdown pages keep writing `<HomeCinematic />` unchanged. The engine's component hook
* matches the parser's own html token, which means VitePress keeps rendering these pages
* the old way while this renders them the new way, from one source.
* Markdown pages write `<HomeCinematic />` and the engine's component hook matches it as
* an html token, so the tag stays ordinary markup and the page stays readable as prose.
*/
import { homeCopy } from './langs/home-copy.ts';
import { renderPackageFacts } from './package-facts.ts';
import { demoCopy } from './langs/demo-copy.ts';
import { localeHref } from './langs/locales.ts';
import type { LocaleDef } from './config.ts';
Expand All @@ -25,6 +25,7 @@ import { resolveLinkFrom } from './links.ts';
/** The component hooks the markdown renderer is configured with. */
export const componentRenderers = {
HomeCinematic: () => renderHome(currentLocale()),
PackageFacts: (attrs: string) => renderPackageFacts(attrs),
GlassPlayground: () => renderGlassPlayground(currentLocale()),
IconGallery: () => renderIconGallery(currentLocale()),
Loading: () => renderLoadingGallery(),
Expand Down Expand Up @@ -148,8 +149,7 @@ export const renderHome = (locale: LocaleDef): string => {
t.pillars
.map(
(p, i) =>
`<a class="pillar reveal" data-reveal data-tilt ${rd(i)} href="${esc(href(p.link))}">` +
`<span class="spotlight" aria-hidden="true"></span>` +
`<a class="pillar reveal" data-reveal ${rd(i)} href="${esc(href(p.link))}">` +
`<span class="pillar-icon" data-kind="${esc(p.kind)}">${icon(p.kind)}</span>` +
`<h3>${esc(p.title)}</h3><p>${esc(p.desc)}</p>` +
`<span class="pillar-more">${esc(p.more)} ${ARROW_SM}</span></a>`,
Expand Down
23 changes: 20 additions & 3 deletions packages/docs/build/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,28 @@ const urlForBase = (baseRel: string, locale: LocaleDef): string => {
return `${prefix}/${stem}`;
};

/** Always `<dir>/index.html`, so URLs need no extension and no redirect sits between. */
/**
* Where a page is written, which decides the URL the host actually serves.
*
* The trailing slash already carries the distinction: `foo/index.md` produced `/foo/`
* and `foo.md` produced `/foo`. The file layout has to follow it — `foo.html` for the
* second, not `foo/index.html`.
*
* Getting this wrong does not break a page. Cloudflare Pages serves `foo/index.html` at
* `/foo/` and **308s `/foo` to it**, so every canonical and sitemap URL without a
* trailing slash — 904 of them — quietly named a redirect instead of a page. That is the
* same failure packages/docs hit once before in mirror image, and it is invisible from a
* browser: the page loads, just one hop late and under a different URL than the one it
* claims to be canonical for.
*
* Comparing canonicals between the two engines does not catch it either. The strings are
* identical; it is the file layout underneath them that changed.
*/
const outFileFor = (url: string, kind: DocKind): string => {
if (kind === 'notfound') return '404.html';
const clean = url.replace(/^\/|\/$/g, '');
return clean ? `${clean}/index.html` : 'index.html';
if (url === '/') return 'index.html';
const clean = url.replace(/^\//, '');
return clean.endsWith('/') ? `${clean}index.html` : `${clean}.html`;
};

/**
Expand Down
30 changes: 30 additions & 0 deletions packages/docs/build/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Development server for the documentation site: rebuild on change, serve through the
* engine's host-accurate server.
*
* Only the eight prose trees and the two asset directories are watched, never the package
* root. `dist/` lives under the root and the build writes into it, so a recursive watch
* there would retrigger itself on its own output and rebuild forever.
*
* A markdown change re-renders pages only. Styles and client code go back through vite,
* which is the slow half, so those are declared as full rebuilds.
*/
import { join } from 'node:path';
import { createDevServer } from 'ranpress';
import { LOCALES } from './config.ts';
import { build, DIST_DIR, ROOT } from './build.ts';

const isMarkdown = (file: string): boolean => file.endsWith('.md');

await createDevServer({
distDir: DIST_DIR,
port: Number(process.env.PORT ?? 4173),
label: 'docs',
rebuild: (_reason, full) => build({ skipAssets: !full }),
watch: [
// `src/` for the root locale, `<dir>/src/` for every other one.
...LOCALES.map((locale) => ({ dir: join(ROOT, locale.dir, 'src'), match: isMarkdown })),
{ dir: join(ROOT, 'styles'), full: true },
{ dir: join(ROOT, 'client'), full: true },
],
});
7 changes: 4 additions & 3 deletions packages/docs/build/langs/demo-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
* the `<Demo>` examples inside the component pages keep their English labels: each one
* mirrors the code block printed underneath it.
*
* These strings are plain per-locale data rather than vue-i18n entries because both
* components render during VitePress's SSR pass, where the app's i18n plugin is installed
* asynchronously (see `theme/index.ts`) and `useI18n()` would therefore not be available.
* These strings are plain per-locale data rather than an i18n runtime's entries. vue-i18n
* used to be installed here and reached the app only after an `await`, so it was
* unavailable during the server pass these render in — which is why the tables exist, and
* why adding a runtime back would reintroduce the problem.
*/

interface GlassStrings {
Expand Down
18 changes: 9 additions & 9 deletions packages/docs/build/langs/locales.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
/**
* The site's locale registry — the single list every other piece of i18n machinery reads:
* `config.ts` (VitePress `locales`, hreflang, `og:locale`), the sidebar builder, the
* language switcher and `bin/build.sh`'s llms-full.txt walk.
* `seo.ts` (hreflang, `og:locale`), the sidebar builder, the language switcher and
* `bin/build.sh`'s llms-full.txt walk.
*
* Adding a language means adding one row here plus one file under `messages/`. Anything
* that needs a hard-coded list of languages elsewhere is a bug — it will silently miss the
* next language added.
*/

/** Directory under the VitePress root that holds a locale's pages; `''` is the root locale. */
/** Directory under the package root that holds a locale's pages; `''` is the root locale. */
export type LocaleDir = '' | 'cn' | 'ja' | 'es' | 'pt' | 'ko' | 'de' | 'fa';

export interface LocaleDef {
/** VitePress locale id — the key in `config.locales`. `root` for the default language. */
/** Stable locale id; `root` for the default language. */
id: string;
/** Content directory. Also the URL prefix (`/cn/src/...`). */
dir: LocaleDir;
Expand All @@ -26,8 +26,8 @@ export interface LocaleDef {
rtl?: true;
/**
* Content trees mirrored in this locale, as path prefixes of the *unprefixed* path.
* A link outside these falls back to the English page instead of pointing at a 404 —
* VitePress has no per-page language fallback, a missing page is simply not built.
* A link outside these falls back to the English page instead of pointing at a 404:
* there is no per-page language fallback, a missing page is simply not built.
*/
mirrors: readonly string[];
}
Expand Down Expand Up @@ -77,7 +77,7 @@ export const localeFromUrlPath = (pathname: string): LocaleDef => {
return LOCALES.find((l) => l.dir && l.dir === segment) ?? ROOT_LOCALE;
};

/** Which locale a BCP-47 tag names, e.g. VitePress's `useData().lang`. */
/** Which locale a BCP-47 tag names. */
export const localeFromLang = (lang: string): LocaleDef =>
LOCALES.find((l) => l.lang.toLowerCase() === lang.toLowerCase()) ??
LOCALES.find((l) => lang.toLowerCase().startsWith(`${l.lang.toLowerCase()}-`)) ??
Expand All @@ -86,8 +86,8 @@ export const localeFromLang = (lang: string): LocaleDef =>
/**
* Prefix a site-internal path for a locale — but only when that locale actually mirrors the
* target. An unmirrored path is returned unchanged so the link lands on the English page
* rather than a 404: VitePress has no per-page language fallback, a page missing from a
* locale is simply never built.
* rather than a 404: there is no per-page language fallback, a page missing from a locale
* is simply never built.
*
* External URLs pass through. The home page exists in every locale, so `/` is always
* prefixed.
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/build/langs/structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { EDITOR } from '../common/index.ts';
* prefix; `buildThemeConfig` adds it, and only for locales that actually mirror the page.
*/

/** Shared by `/src/article/` and `/src/note/` — VitePress matches sidebars by path prefix. */
/** Shared by `/src/article/` and `/src/note/` — sidebars are matched by path prefix. */
const articleSidebar: SidebarNode[] = [
{
items: [
Expand Down
4 changes: 2 additions & 2 deletions packages/docs/build/langs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export type LabelKind =

/** One node of the locale-agnostic sidebar/nav tree. */
export interface SidebarNode {
/** Absent on an unlabelled wrapper group — VitePress renders such a group's items flat. */
/** Absent on an unlabelled wrapper group, whose items render flat. */
kind?: LabelKind;
/** Key into a locale's message dictionary; absent together with `kind`. */
key?: string;
Expand Down Expand Up @@ -48,7 +48,7 @@ export interface LocaleMessages {
ui: UiMessages;
}

/** Strings VitePress's default theme renders itself (it ships English only). */
/** Chrome the site renders itself, so it needs a translation per locale. */
export interface UiMessages {
outline: string;
returnToTop: string;
Expand Down
6 changes: 3 additions & 3 deletions packages/docs/build/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ export const navFor = (locale: LocaleDef): NavLink[] => {
};

/**
* The sidebar for a page, matched the way VitePress matches: by path prefix, longest
* first. A page whose path matches no key has no sidebar, which is correct for the home
* page and is how a stray page announces itself.
* The sidebar for a page: by path prefix, longest first. A page whose path matches no key
* has no sidebar, which is correct for the home page and is how a stray page announces
* itself.
*/
export const sidebarFor = (url: string, locale: LocaleDef): SidebarItem[] => {
const { labels } = messagesFor(locale);
Expand Down
87 changes: 87 additions & 0 deletions packages/docs/build/package-facts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Package facts, read from the workspace at build time.
*
* These replaced a row of five shields.io images. The images were the only saturated
* colour on the page, in a visual language belonging to nobody, fetched from two external
* hosts on every page view — and at least one of them was wrong: the badge labelled
* `brotli` reported 3.8 KB, which is the *raw* size of `dist/index.js`.
*
* Only facts that can be proved from the repository are stated. The CI-status and
* download-count badges needed a network call and are gone; a size figure is gone too,
* because no single file is an honest answer for a library whose components load as
* separate chunks — `dist/index.js` is a re-export shim and `dist/button.js` is 0.1 KB.
*/
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

export interface PackageFacts {
name: string;
version: string;
license: string;
formats: string[];
npm: string;
source: string;
}

// Resolved here rather than imported from `build.ts`: that module imports the renderers,
// which import this one, and a top-level `join(ROOT, '..')` then runs before `ROOT` is
// initialised — `ReferenceError: Cannot access 'ROOT' before initialization`.
const PACKAGES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');

const cache = new Map<string, PackageFacts>();

export const readPackageFacts = (name: string): PackageFacts => {
const cached = cache.get(name);
if (cached) return cached;

const dir = join(PACKAGES_DIR, name);
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
version?: string;
license?: string;
exports?: Record<string, unknown>;
};

// Formats are claimed only where the repository proves them: the exports map for
// esm/cjs, and a built directory for iife.
const root = (pkg.exports?.['.'] ?? {}) as Record<string, unknown>;
const formats: string[] = [];
if (root.import) formats.push('esm');
if (root.require) formats.push('cjs');
if (existsSync(join(dir, 'dist', 'iife'))) formats.push('iife');

const facts: PackageFacts = {
name,
version: pkg.version ?? '',
license: pkg.license ?? '',
formats,
npm: `https://www.npmjs.com/package/${name}`,
source: `https://github.com/chaxus/ran/tree/main/packages/${name}`,
};
cache.set(name, facts);
return facts;
};

const ATTR = /package="([a-z0-9-]+)"/i;

/**
* One typographic line, not a grid of labelled cells.
*
* Every item is a value that reads the same in all eight languages — a version, a licence
* identifier, format names, a repository path — so this needs no translated chrome and
* `check:langs` has nothing new to enforce.
*/
export const renderPackageFacts = (attrs: string): string => {
const name = ATTR.exec(attrs)?.[1];
if (!name) throw new Error('<PackageFacts> needs a package="<name>" attribute');
const f = readPackageFacts(name);

const items = [
`<a class="pkg-facts__item" href="${f.npm}">v${f.version}</a>`,
f.license && `<span class="pkg-facts__item">${f.license}</span>`,
f.formats.length && `<span class="pkg-facts__item">${f.formats.join(' · ')}</span>`,
`<a class="pkg-facts__item" href="${f.source}">packages/${f.name}</a>`,
].filter(Boolean);

return `<p class="pkg-facts">${items.join('')}</p>`;
};
10 changes: 10 additions & 0 deletions packages/docs/build/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,17 @@ ${assets.js.map((src) => `<script type="module" src="${src}"></script>`).join('\
* documentation site that is a white flash on every single navigation. Writes nothing
* for "system", leaving `prefers-color-scheme` in charge.
*/
/*
* Runs before first paint: restores the stored theme, and marks that script is running
* at all.
*
* The `js` class is what lets CSS hide something it expects script to bring back. A
* scroll-reveal that sets `opacity: 0` unconditionally is a bet that the observer will
* always run; when it does not, the content below the fold is simply gone, and a reader
* with JavaScript disabled has no way to know there was anything there.
*/
const THEME_BOOTSTRAP = `(function(){try{
document.documentElement.classList.add('js');
var t=localStorage.getItem('ran-theme');
if(t==='dark'||t==='light'){var e=document.documentElement;e.setAttribute('data-ran-theme',t);e.setAttribute('theme',t);}
}catch(e){}})();`;
10 changes: 10 additions & 0 deletions packages/docs/build/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Serve the built `dist/` exactly as Cloudflare Pages will — same bytes, same redirects,
* same 404 status. Nothing is rebuilt: run `pnpm -F docs build` first.
*/
import { createPreviewServer } from 'ranpress';
import { DIST_DIR } from './build.ts';

const port = Number(process.env.PORT ?? 4174);
createPreviewServer({ distDir: DIST_DIR, port });
console.log(`\n docs preview http://localhost:${port}\n`);
17 changes: 0 additions & 17 deletions packages/docs/client/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,23 +36,6 @@ const mountHome = (): void => {
wireCopy(button, button.dataset.copy ?? '');
}

// A subtle 3D tilt that follows the pointer across a card.
for (const card of root.querySelectorAll<HTMLElement>('[data-tilt]')) {
card.addEventListener('pointermove', (event) => {
const r = card.getBoundingClientRect();
const mx = event.clientX - r.left;
const my = event.clientY - r.top;
card.style.setProperty('--mx', `${mx}px`);
card.style.setProperty('--my', `${my}px`);
card.style.setProperty('--rx', `${((my / r.height) * 2 - 1) * -3}deg`);
card.style.setProperty('--ry', `${((mx / r.width) * 2 - 1) * 3}deg`);
});
card.addEventListener('pointerleave', () => {
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});
}

const reveals = [...root.querySelectorAll<HTMLElement>('[data-reveal]')];
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
Expand Down
Loading