Skip to content
Open
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
54 changes: 23 additions & 31 deletions docs-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@
<meta name="description" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

<script>
// Set the color scheme BEFORE any CSS loads, so the first paint matches
// the user's preference. ember-primitives/color-scheme reads the same
// localStorage key when it boots, so this stays in sync afterward.
// Without this, SSR'd HTML renders with the server's default scheme
// and the page flashes when client JS detects the real preference.
(function () {
try {
var key = "ember-primitives/color-scheme#local-preference";
var pref =
localStorage.getItem(key) ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.style.colorScheme = pref;
document.documentElement.dataset.theme = pref;
} catch (_) {
/* localStorage may be unavailable; skip */
}
})();
</script>

<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.0.6/css/pico.min.css"
Expand All @@ -16,39 +36,11 @@
</script>
<link integrity="" rel="stylesheet" href="./src/styles/app.css" />

<style>
#kolay__loading {
position: fixed;
inset: 0;
background: #111;
color: white;
font-size: 4rem;
z-index: 10;
font-family: "System UI", "System";
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 4rem;

[aria-busy="true"] {
background: #111;
}
}
</style>
<!-- VITE_EMBER_SSR_HEAD -->
</head>
<body>
<!-- VITE_EMBER_SSR_BODY -->
<script src="/@embroider/virtual/vendor.js"></script>
<script type="module">
import Application from "#src/app.ts";
import environment from "#config";

Application.create(environment.APP);
</script>

<div id="kolay__loading">
Kolay
<div aria-busy="true"></div>
</div>
<script type="module" src="/src/entry.ts"></script>
</body>
</html>
18 changes: 9 additions & 9 deletions docs-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"./*": "./src/*"
},
"scripts": {
"build:app": "vite build",
"build": "vite build",
"build:tests": "NODE_ENV=development vite build --mode development",
"lint": "pnpm -w turbo --filter docs-app _:lint",
"lint:fix": "pnpm -w turbo --filter docs-app _:lint:fix",
Expand All @@ -26,9 +26,8 @@
"lint:js:fix": "eslint . --fix",
"lint:prettier": "prettier . --check",
"lint:prettier:fix": "prettier . --write",
"start": "pnpm start:vite",
"start:vite": "vite",
"start:test": "pnpm start:vite --open /tests/",
"start": "vite",
"start:test": "pnpm start --open /tests/",
"test:ember": "pnpm build:tests && pnpm test:browser",
"test:browser": "testem ci --port 0"
},
Expand All @@ -41,16 +40,17 @@
"@ember/string": "^4.0.1",
"@ember/test-helpers": "^5.4.1",
"@ember/test-waiters": "^4.0.0",
"@embroider/compat": "^4.1.13",
"@embroider/core": "^4.4.3",
"@embroider/macros": "^1.19.7",
"@embroider/vite": "^1.5.2",
"@embroider/compat": "^4.1.18",
"@embroider/core": "^4.4.7",
"@embroider/macros": "^1.20.2",
"@embroider/vite": "^1.7.3",
"@glimmer/component": "^2.0.0",
"@glint/ember-tsc": "1.1.1",
"@glint/template": "1.7.4",
"@glint/tsserver-plugin": "2.1.0",
"@nullvoxpopuli/eslint-configs": "^5.5.0",
"@rollup/plugin-babel": "^6.0.4",
"@types/node": "^25.9.1",
"@types/qunit": "^2.19.13",
"babel-plugin-ember-template-compilation": "^4.0.0",
"concurrently": "^9.2.1",
Expand Down Expand Up @@ -78,6 +78,7 @@
"typescript": "^5.9.3",
"unplugin-info": "^1.2.4",
"vite": "8.0.14",
"vite-ember-ssr": "^0.1.0",
"vite-plugin-inspect": "^11.3.3",
"vite-plugin-wasm": "^3.5.0"
},
Expand All @@ -100,7 +101,6 @@
"ember-async-data": "^2.0.0",
"ember-cached-decorator-polyfill": "^1.0.2",
"ember-mobile-menu": "^6.0.0",
"ember-route-template": "^1.0.3",
"kolay": "workspace:^",
"nvp.ui": "^0.5.3",
"reactiveweb": "1.9.1",
Expand Down
187 changes: 187 additions & 0 deletions docs-app/src/app-ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import Application from '@ember/application';
import { settled } from '@ember/test-helpers';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';

import PageTitleService from 'ember-page-title/services/page-title';
import Resolver from 'ember-resolver';

import config from './config.ts';
import Router from './router.ts';
import SsrApplicationRoute from './routes/application-ssr.ts';

// SSR-only resolver registry. Built fresh here (not imported from registry.ts)
// so we can EXCLUDE `./routes/application.ts` from the SSR module graph —
// that route eagerly registers `import('babel-plugin-ember-template-compilation')`
// and other heavy dynamic-import chunks for kolay's runtime markdown compiler.
// Vite static-analyzes those import() calls and bundles the whole transitive
// graph (content-tag's `.cjs`, babel-import-util's CJS surface, etc.) into
// whichever build sees them; in the SSG/SSR pipeline that graph trips
// vite-ember-ssr's CJS shim against rolldown's stricter parser. SSG never
// invokes the runtime compiler, so we swap in the lean SsrApplicationRoute.
const appName = 'docs-app';

function formatAsResolverEntries(imports: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(imports).map(([k, v]) => [
k
.replace(/\.gjs\.md$/, '')
.replace(/\.g?(j|t)s$/, '')
.replace(/^\.\//, `${appName}/`),
v,
])
);
}

const ssrRegistry: Record<string, unknown> = {
[`${appName}/router`]: Router,
[`${appName}/services/page-title`]: PageTitleService,
[`${appName}/routes/application`]: { default: SsrApplicationRoute },
...formatAsResolverEntries(import.meta.glob('./templates/**/*.{gjs,gts,js,ts}', { eager: true })),
...formatAsResolverEntries(import.meta.glob('./services/**/*.{js,ts}', { eager: true })),
// Pull in everything under routes/ EXCEPT the client application route.
...formatAsResolverEntries(
import.meta.glob(['./routes/**/*.{js,ts}', '!./routes/application.{js,ts}'], {
eager: true,
})
),
};

class App extends Application {
modulePrefix = config.modulePrefix;
Resolver = Resolver.withModules(ssrRegistry);
}

// Patch fetch for the two cases SSG hits that Node's built-in fetch can't
// handle:
//
// 1. `fetch('/<dest>/<pkg>.json')` from kolay's api-docs runtime — Node has
// no HTTP server running at HappyDOM's `http://localhost/` so the fetch
// rejects and `getPromiseState` writes "Promise rejected while waiting
// to resolve" into the prerendered HTML. Read the JSON from disk.
//
// 2. `fetch(new URL('content_tag_bg.wasm', import.meta.url))` from content-tag
// / wasm-bindgen init — that resolves to a `file://` URL, and undici
// returns `Error: not implemented... yet...` for the `file:` scheme.
// Read the wasm bytes from disk with the right content-type.
const LOCAL_ASSET_PREFIXES = ['/docs/', '/api-docs/'];
let fetchPatched = false;

function patchFetchForLocalAssets() {
if (fetchPatched) return;
fetchPatched = true;

const realFetch = globalThis.fetch;
const clientDir = resolve(process.cwd(), 'dist/client');

globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;

try {
// file:// — undici can't fetch these, but we can read them. Vite
// emits hashed asset URLs (e.g. `content_tag_bg-CrL58-hw.wasm`)
// only into the client build dir, not the SSG temp build, so look
// up by base name in dist/client/assets/ as a fallback.
if (url.startsWith('file://')) {
const { fileURLToPath } = await import('node:url');
const path = await import('node:path');
const filePath = fileURLToPath(url);
let body: Buffer | null = null;

try {
body = await readFile(filePath);
} catch {
// Try the client assets dir, matching by base-name prefix
// (Vite adds a hash before the extension).
const { readdir } = await import('node:fs/promises');
const base = path.basename(filePath, path.extname(filePath));
const ext = path.extname(filePath);
const clientAssets = path.resolve(process.cwd(), 'dist/client/assets');
const candidates = await readdir(clientAssets);
const match = candidates.find((f) => f.startsWith(base) && f.endsWith(ext));

if (match) body = await readFile(path.join(clientAssets, match));
}

if (body) {
const contentType = filePath.endsWith('.wasm')
? 'application/wasm'
: filePath.endsWith('.json')
? 'application/json'
: 'application/octet-stream';

return new Response(new Uint8Array(body), {
status: 200,
headers: { 'content-type': contentType },
});
}
}

// http://localhost/{docs,api-docs}/... — emitted by the client build
// into dist/client, read straight from disk.
const parsed = new URL(url, 'http://localhost/');
const isLocal = LOCAL_ASSET_PREFIXES.some((p) => parsed.pathname.startsWith(p));

if (parsed.host === 'localhost' && isLocal) {
const file = resolve(clientDir, parsed.pathname.slice(1));
const body = await readFile(file);

return new Response(body, {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
} catch {
// Fall through to real fetch on any parse / read failure; getPromiseState
// will then capture whatever real fetch returns (or its rejection).
}

return realFetch(input, init);
}) as typeof fetch;
}

export function createSsrApp() {
const app = App.create({ ...config.APP, autoboot: false });

// Patched after limber's app-ssr (apps/repl/app/app-ssr.ts): bake
// `await settled()` into `app.visit` so the visit promise resolves only
// when Ember's run loop, pending timers, and `@ember/test-waiters`
// (reactiveweb's getPromiseState for the .gjs.md page loader, etc.)
// have drained. Without this we'd rely on vite-ember-ssr's worker
// calling `awaitSettled(10_000)` after visit, which fires the 10s
// timeout on cold-start renders and captures the DOM mid-page-load.
const originalVisit = app.visit.bind(app);

Object.assign(app, {
visit: async (...args: Parameters<typeof originalVisit>) => {
// Install the fetch patch lazily, on first visit. vite-ember-ssr's
// worker overwrites globalThis.fetch with its own (shoebox) wrapper
// AFTER our createSsrApp returns, so patching there gets clobbered.
// By the time visit runs the worker setup is complete, and our
// wrapper sits in front of theirs.
patchFetchForLocalAssets();

const instance = await originalVisit(...args);

// Settle in a loop, not just once. Kolay's `<Page>` first renders the
// page Prose only after the Selected loader resolves; that Prose may
// contain `<APIDocs>` / `<Load>` which kick off ANOTHER `fetch()` and
// register a fresh `waitForPromise` waiter for it. If we only awaited
// settled() once, we'd return before that second waiter had a chance
// to be created, capturing the DOM mid-typedoc-fetch with a "Loading
// api docs..." placeholder. Re-checking with a microtask between calls
// catches newly-scheduled async work, and we cap iterations so a
// genuinely-stuck render still terminates.
for (let i = 0; i < 10; i++) {
await settled();
await new Promise((r) => setTimeout(r, 0));
// After the microtask drain, if nothing new ran, settled() returns
// immediately on the next iteration and we exit on the next round.
}

return instance;
},
});

return app;
}
37 changes: 37 additions & 0 deletions docs-app/src/entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { prefetchPage, prewarmTypedocCaches } from 'kolay';
import { bootRehydrated, shouldRehydrate } from 'vite-ember-ssr/client';

import Application from './app.ts';
import config from './config.ts';

// During SSR rehydration, eagerly load the current URL's page module AND
// every configured typedoc package's JSON BEFORE booting Ember:
//
// - `prefetchPage` populates Kolay's path-keyed page module cache so
// `Selected.loader` returns a synchronous `{ resolved }` state on its
// first access. Without it, Selected would briefly render `<:pending>`
// for one microtask while the dynamic import resolves, flashing the
// loading state over the SSG'd prose.
//
// - `prewarmTypedocCaches` fetches + deserializes every `apiDocs({ packages })`
// entry so the first render of `<APIDocs>` / `<Load>` has a synchronous
// `request.resolved` and the SSG'd `<section>` of typedoc declarations
// stays mounted through rehydration.
//
// Skip on plain-boot pages: there's nothing in the DOM to mismatch against.
if (shouldRehydrate()) {
try {
await Promise.all([prefetchPage(window.location.pathname), prewarmTypedocCaches()]);
} catch {
// Page not in manifest, network failure, etc. — fall through to normal
// boot. The async Selected / Load resources will handle it (possibly
// with a brief flash).
}
}

// When the page was server-rendered, bootRehydrated() creates the app with
// autoboot: false and calls app.visit(url, { _renderMode: 'rehydrate' }) so
// Glimmer attaches to the existing DOM in place instead of replacing it.
// When the page was NOT server-rendered (e.g. `pnpm start:vite` without the
// SSR server), it falls back to a normal Application.create(config.APP).
bootRehydrated(Application, config);
11 changes: 8 additions & 3 deletions docs-app/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,14 @@ function formatAsResolverEntries(imports: Record<string, unknown>) {
* - we design a new routing system
*/
const resolverRegistry = {
...formatAsResolverEntries(
import.meta.glob('./templates/**/*.{gjs,gts,js,ts,md}', { eager: true })
),
// .gjs.md / .gts.md / .md files are loaded by kolay's Selected service via
// dynamic imports from the `kolay/compiled-docs:virtual` pages map — they
// don't need to live in Ember's resolver. Including them here as eager
// imports forces every compiled-page chunk to evaluate at app boot, which
// in the production SSR bundle creates a circular import: each chunk's
// top-level `templateOnly()` call runs before app-ssr.js has initialized
// the binding, throwing `templateOnly is not a function`.
...formatAsResolverEntries(import.meta.glob('./templates/**/*.{gjs,gts,js,ts}', { eager: true })),
...formatAsResolverEntries(import.meta.glob('./services/**/*.{js,ts}', { eager: true })),
...formatAsResolverEntries(import.meta.glob('./routes/**/*.{js,ts}', { eager: true })),
[`${appName}/router`]: Router,
Expand Down
Loading
Loading