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
24 changes: 24 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,30 @@ hydrates when `#root` already has children (a real build/preview load) and falls
`release-website.yml` needed no changes - `pnpm run build` still ends with static files in
`website/dist`, just with real markup already in them.

**No-JS visitors get a warning banner, kept outside the React tree on purpose.** A `<noscript>`
wrapping JSX children inside a hydrated React app is unreliable: React's hydration reconciliation
ends up creating the children as real DOM nodes even with JS enabled (React uses `createElement`/
`appendChild`, not the HTML parser's noscript-content-as-text rule the initial static HTML got right
by luck). The banner lives directly in `index.html` instead, so React never touches it and the
browser's native noscript hiding is the only thing controlling its visibility. It's `position:
sticky` so it stays visible while scrolling instead of disappearing with the rest of the page - the
one warning on this site that matters most, since the whole point of the page is the console demo,
and a visitor who can't get it to open needs to know why. A `<style>` scoped inside the same
`<noscript>` (so it too only exists when scripting is disabled) sets Nav's `<header>` to `position:
static`, ceding the sticky-top-0 spot to the banner - without it, both elements would fight over
`top: 0` while scrolling and visually overlap.

**`preview:website:watch` rebuilds on save; no new dependencies for it.** `preview:website` (see
above) is a one-shot build-then-serve, deliberately with no watch mode - a full rebuild here is
`tsc -b` plus two `vite build` passes plus the prerender script, several seconds of unrelated
tooling to wire hot-reload around for something reached for occasionally, not run all day like
`dev:website`. Once it's a full-rebuild-on-change loop instead of true HMR, though, the fix is a
plain debounce-and-shell-out: `scripts/watch-preview.mjs` uses Node's built-in recursive `fs.watch`
on `src/` and `index.html` to re-run `pnpm run build`, alongside one long-lived `vite preview`
process - which reads `dist/` from disk per request, so it reflects each rebuild without restarting.
No `chokidar` or `concurrently` added for this; a watch-and-shell-out loop this small doesn't need
either.

## Where to find the rest

- [README.md](README.md) - installation, configuration, and the `replEnabled: false` escape hatch
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ pnpm build:website
# prerendered HTML included) - no hot reload, refresh manually after a change
pnpm preview:website

# Same, but rebuilds automatically on every change under website/src or
# website/index.html - still no hot reload, just refresh the browser yourself
pnpm preview:website:watch

# Pull a @codehacks/virtual-console release into the website (see Releasing below)
pnpm bump:website:latest
```
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"build:website": "pnpm run install:website && pnpm --dir website build",

"preview:website": "pnpm run build:website && pnpm --dir website preview",
"preview:website:watch": "pnpm run install:website && pnpm --dir website preview:watch",

"install:import:published": "pnpm --dir examples/published/react-vite-import install",
"install:vite-plugin:published": "pnpm --dir examples/published/react-vite-plugin install",
Expand Down
20 changes: 13 additions & 7 deletions website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,30 @@ Standalone project (own `pnpm-workspace.yaml`), same reasoning as `examples/publ
# from the repo root
pnpm dev:website
pnpm build:website
pnpm preview:website # build + serve the real static output (dist/), prerendered HTML included
pnpm preview:website # build once, then serve the real static output (dist/)
pnpm preview:website:watch # same, but rebuilds on every save - refresh the browser yourself

# or directly
cd website
pnpm install
pnpm dev
pnpm build && pnpm preview
pnpm preview:watch
```

`pnpm dev` (Vite's dev server) never runs the prerender step - `index.html`'s `#root` starts empty
there and `main.tsx` client-renders into it (see [DECISIONS.md](../DECISIONS.md)). To see and debug
the actual prerendered HTML a no-JS client or crawler would get - or to check something that only
happens in the production build - use `preview:website` (or `pnpm build && pnpm preview` from
`website/`) instead. It's a plain build-then-serve, not a dev server: no hot reload or watch mode,
so re-run it after each change. That's a deliberate simplicity trade, not a limitation to fix -
wiring up a watcher for a full rebuild (`tsc` + two `vite build` passes + the prerender script) on
every change would add real complexity for a workflow that's normally reached for occasionally, not
kept running all day like `pnpm dev`.
happens in the production build - use one of the `preview*` commands instead, which build (prerender
step included) and serve the real `dist/` output. `preview:website` is a one-shot build-then-serve;
`preview:website:watch` (`scripts/watch-preview.mjs`) rebuilds automatically on every change under
`src/` or `index.html` and leaves `vite preview` running, so you just refresh the browser after each
rebuild finishes. No hot reload/HMR - a rebuild here is `tsc` + two `vite build` passes + the
prerender script, not a single Vite module reload, so wiring up HMR on top of it isn't worth the
complexity for a workflow you reach for to debug something prerender-specific, not one you'd keep
open all day like `pnpm dev`. The watcher itself is a plain `node:fs` recursive watch with no new
dependencies - deliberately, since a debounced full-rebuild loop this small doesn't need a `chokidar`
or `concurrently` pulled in for it.

## Deployment

Expand Down
16 changes: 12 additions & 4 deletions website/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,22 @@

<body class="bg-neutral-950 text-neutral-100">
<noscript>
<div class="border-b border-amber-400/20 bg-amber-400/[0.06]">
<div class="mx-auto flex max-w-6xl items-center justify-center gap-2 px-6 py-2.5 text-center text-sm text-amber-200">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0" aria-hidden="true">
<!-- Only active in no-JS (that's what being inside <noscript> means): cedes the
sticky-top-0 spot to the banner below instead of Nav (Nav.tsx), which would
otherwise fight it for that spot while scrolling. -->
<style>
header {
position: static !important;
}
</style>
<div class="sticky top-0 z-50 border-b border-amber-400/30 bg-amber-400/10 backdrop-blur-sm">
<div class="mx-auto flex max-w-6xl items-center justify-center gap-2.5 px-6 py-3 text-center text-sm font-medium text-amber-200">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5 shrink-0" aria-hidden="true">
<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
<span>JavaScript is disabled, so the live console demo below won't open — everything else on this page still works.</span>
<span>JavaScript is disabled — the live console demo won't open. Enable it in your browser to try it.</span>
</div>
</div>
</noscript>
Expand Down
1 change: 1 addition & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build && vite build --config vite.ssr.config.ts --ssr src/entry-server.tsx --outDir dist-ssr && node scripts/prerender.mjs",
"preview": "vite preview",
"preview:watch": "node scripts/watch-preview.mjs",
"typecheck": "tsc -b --noEmit",
"clean": "rm -rf dist dist-ssr .vite"
},
Expand Down
52 changes: 52 additions & 0 deletions website/scripts/watch-preview.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Rebuilds on every source change and serves the result, for debugging the
// prerendered/SSR output without a manual build+preview cycle each time. No
// hot reload: `vite preview` reads dist/ from disk per request, so a
// browser refresh after each rebuild is enough - see website/README.md for
// why this doesn't try to preserve HMR too.
import { spawn } from 'node:child_process';
import { watch } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const websiteDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));

function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd: websiteDir, stdio: 'inherit', shell: true });
child.on('exit', code => (code === 0 ? resolve() : reject(new Error(`${command} exited with code ${code}`))));
});
}

let building = false;
let rebuildQueued = false;

async function rebuild() {
if (building) {
rebuildQueued = true;
return;
}

building = true;
console.log('\n[watch] building...');

try {
await run('pnpm', ['run', 'build']);
console.log('[watch] build done - refresh your browser');
} catch (error) {
console.error('[watch] build failed:', error.message);
}

building = false;

if (rebuildQueued) {
rebuildQueued = false;
await rebuild();
}
}

await rebuild();

spawn('pnpm', ['run', 'preview'], { cwd: websiteDir, stdio: 'inherit', shell: true });

watch(path.join(websiteDir, 'src'), { recursive: true }, () => rebuild());
watch(path.join(websiteDir, 'index.html'), () => rebuild());
Loading