A Vite + React + TypeScript + Tailwind v4 site under website/, built from the soulseek-rs design system so the site and the TUI stay one product. The colour tokens in src/index.css are the same values as soulseek-rs/src/ui/styles.rs; change one, change both. The hero is a working recreation of the TUI: it plays one scripted run and then takes the keyboard, or lands on the resting state directly under prefers-reduced-motion. Its sample data is invented or public domain, per the tone-of-voice guardrail against naming copyrighted material. Static output rather than an SPA shell. `bun run build` compiles the client, compiles the same app for Node, then renders every route in src/lib/routes.ts to its own file with that route's title, description and canonical URL. A direct link to /install therefore answers 200 with real markup instead of GitHub's 404 page. routes.ts is keyed into App.tsx by literal path, so adding one without the other fails typecheck rather than shipping a page whose body says "not found" under the right title. Theme is resolved once by an inline script before first paint and adopted by React through useSyncExternalStore, so there is no flash and no hydration mismatch on the toggle. Light mode re-cuts the semantic hues darker: phosphor and tape read on vinyl but wash out on manila. CI and release skip website-only pushes, and Pages skips Rust-only ones. The two Rust workflows share one paths-ignore list so they can only skip together, which keeps the release ci-gate from polling for a run that was never queued. That gate also now treats a cancelled CI as "keep waiting" rather than a failure, since cancel-in-progress cancels the older SHA on every back-to-back push.
92 lines
3.3 KiB
TypeScript
92 lines
3.3 KiB
TypeScript
/*
|
|
* Turns the SPA build into real static files.
|
|
*
|
|
* Without this, GitHub Pages has no file at /install and answers a direct
|
|
* link with its 404 page. Writing one HTML file per route means every URL
|
|
* returns 200 with its own title, description, and fully rendered body; the
|
|
* client then hydrates it into the same interactive app.
|
|
*
|
|
* Run after both `vite build` and `vite build --ssr`.
|
|
*/
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
import { dirname, join } from 'node:path'
|
|
|
|
import { NOT_FOUND_META, ROUTES, type RouteMeta } from '../src/lib/routes.ts'
|
|
|
|
/*
|
|
* Held in a variable so TypeScript does not try to resolve it: the module is
|
|
* a build artefact, and `bun run typecheck` runs before `vite build --ssr`
|
|
* has produced it. The cast is the contract with src/entry-server.tsx.
|
|
*/
|
|
const SSR_ENTRY = '../dist-ssr/entry-server.js'
|
|
const { render } = (await import(SSR_ENTRY)) as {
|
|
render: (location: string) => string
|
|
}
|
|
|
|
const DIST = join(import.meta.dirname, '..', 'dist')
|
|
|
|
const SITE_URL = 'https://michel.github.io/soulseek-rs/'
|
|
|
|
const escapeAttr = (value: string): string =>
|
|
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
|
|
|
/** Replaces the content of one `<meta>` matched by attribute and value. */
|
|
const setMeta = (html: string, attr: string, name: string, value: string): string =>
|
|
html.replace(
|
|
new RegExp(`(<meta\\s+${attr}="${name}"\\s+content=")[\\s\\S]*?(")`),
|
|
`$1${escapeAttr(value)}$2`,
|
|
)
|
|
|
|
/**
|
|
* Swaps every per-page tag in the template for the route's own.
|
|
*
|
|
* All five are listed here together on purpose: og:title and og:url used to be
|
|
* left alone, so each prerendered page advertised the site title and the
|
|
* homepage URL to anything reading Open Graph.
|
|
*/
|
|
const applyMeta = (html: string, meta: RouteMeta): string => {
|
|
const canonical = meta.path === '/' ? SITE_URL : `${SITE_URL}${meta.path.slice(1)}/`
|
|
let out = html.replace(
|
|
/<title>[\s\S]*?<\/title>/,
|
|
`<title>${escapeAttr(meta.title)}</title>`,
|
|
)
|
|
out = setMeta(out, 'name', 'description', meta.description)
|
|
out = setMeta(out, 'property', 'og:title', meta.title)
|
|
out = setMeta(out, 'property', 'og:description', meta.description)
|
|
out = setMeta(out, 'property', 'og:url', canonical)
|
|
return out.replace(
|
|
/(<link\s+rel="canonical"\s+href=")[\s\S]*?(")/,
|
|
`$1${escapeAttr(canonical)}$2`,
|
|
)
|
|
}
|
|
|
|
const writePage = async (
|
|
template: string,
|
|
meta: RouteMeta,
|
|
outPath: string,
|
|
location: string,
|
|
): Promise<void> => {
|
|
const html = applyMeta(template, meta).replace(
|
|
'<div id="root"></div>',
|
|
`<div id="root">${render(location)}</div>`,
|
|
)
|
|
const target = join(DIST, outPath)
|
|
await mkdir(dirname(target), { recursive: true })
|
|
await writeFile(target, html, 'utf8')
|
|
console.log(` ${outPath}`)
|
|
}
|
|
|
|
const template = await readFile(join(DIST, 'index.html'), 'utf8')
|
|
|
|
console.log('prerendering:')
|
|
for (const route of ROUTES) {
|
|
const outPath = route.path === '/' ? 'index.html' : `${route.path.slice(1)}/index.html`
|
|
await writePage(template, route, outPath, route.path)
|
|
}
|
|
|
|
// GitHub Pages serves this whenever no file matches, which after the loop
|
|
// above is only ever a genuinely unknown path.
|
|
await writePage(template, NOT_FOUND_META, '404.html', '/__not_found__')
|
|
|
|
console.log(`done — ${String(ROUTES.length + 1)} pages`)
|