Repository navigation
Website Tech Stack
This page lists what the website is built with, how website/ is laid out and configured for GitHub Pages, and how it is built, checked, deployed and kept up to date. Use it when setting up the site, changing its configuration or adding a package.
Decided items come from the decision log on Design System; Proposed items are not approved. Versions were checked on the npm registry on 2026-10-24.
| Piece | Version on 2026-10-24 | Why |
|---|---|---|
Next.js (App Router, output: 'export') |
16.4.3 | Builds every page to static HTML, so GitHub Pages can serve it with no server (static exports). |
| React, React DOM | 19.3.1 | Required by Next.js 16 and Fumadocs UI. |
| fumadocs-core | 16.17.8 | Page tree, search index, llms(), Markdown utilities. |
| fumadocs-ui | 16.17.8 | Notebook layout, sidebar, table of contents, search dialog, tabs, callouts, code blocks. Same version as fumadocs-core (an exact peer dependency). |
| fumadocs-mdx | 15.4.10 | Compiles content/docs/ and gives each page its TOC and processed Markdown. |
Tailwind CSS v4, @tailwindcss/postcss
|
4.3.3 | Fumadocs UI is styled with Tailwind v4; the tokens become @theme variables (Design Tokens). |
TypeScript, @types/*
|
7.0.2 | Type checks the site, including the demo's use of the workclock types. |
| lucide-react | 1.61.0 | Icons; already a fumadocs-ui dependency (Icons and Illustration). |
@playwright/test, @axe-core/playwright
|
exact versions, dev only | Accessibility checks on the built site (Accessibility). |
| next/font, next/og | part of Next.js | Self-hosted fonts; social cards drawn at build time. |
| Shiki, Fumadocs static search | through Fumadocs | Code highlighted at build time; a search index searched in the browser. |
| workclock | built from ../js
|
The demo and the static week strips (below). |
Next.js 16 builds with Turbopack by default (Next.js 16) and needs Node.js 20.9 or later; CI uses Node.js 26, like the release workflow.
The layout follows Fumadocs' static Next.js example, examples/next-static (create-fumadocs-app template +next+fuma-docs-mdx+static), with collections defined by the Fumadocs MDX Macro API in lib/source.ts. Every route handler's path ends in a file extension (search.json, content.md, image.png), so trailingSlash does not touch it and GitHub Pages picks the content type from the extension.
website/
├── package.json exact versions, "private": true
├── package-lock.json committed; CI runs npm ci
├── .npmrc save-exact=true
├── next.config.mjs
├── postcss.config.mjs { plugins: { '@tailwindcss/postcss': {} } }
├── source.config.ts global MDX options (Shiki themes)
├── playwright.config.ts
├── tsconfig.json
├── app/
│ ├── layout.tsx <html>, fonts, Provider, metadataBase
│ ├── global.css Tailwind, Fumadocs CSS, tokens, overrides
│ ├── not-found.tsx → out/404.html
│ ├── opengraph-image.png site card, from Canva
│ ├── opengraph-image.alt.txt
│ ├── icon.svg, apple-icon.png, favicon.ico
│ ├── (home)/
│ │ ├── layout.tsx Fumadocs HomeLayout
│ │ ├── page.tsx landing page
│ │ └── playground/page.tsx
│ ├── docs/
│ │ ├── layout.tsx DocsLayout from fumadocs-ui/layouts/notebook, nav.mode 'top'
│ │ └── [[...slug]]/page.tsx DocsPage from fumadocs-ui/layouts/notebook/page
│ ├── api/search.json/route.ts → out/api/search.json
│ ├── llms.txt/route.ts → out/llms.txt
│ ├── llms-full.txt/route.ts → out/llms-full.txt
│ ├── llms.mdx/docs/[[...slug]]/route.ts → out/llms.mdx/docs/<slug>/content.md
│ └── og/docs/[...slug]/route.tsx → out/og/docs/<slug>/image.png
├── assets/fonts/ InstrumentSans-SemiBold.ttf, OFL.txt (for next/og)
├── components/ provider, search, mdx, page-actions, install-tabs,
│ feedback-link, site-footer, badge, skip-link,
│ week-strip/, use-workclock.ts
├── content/docs/ the docs pages and meta.json files
├── lib/
│ ├── source.ts defineDocs + loader()
│ ├── shared.ts site URL, route helpers
│ └── layout.shared.tsx header options for both layouts
├── scripts/copy-markdown.mjs Proposed post-build step
├── tests/a11y.spec.ts
└── public/ files served as they are
The example's lib/cn.ts imports the cn package, which is not approved; write the few class-joining calls by hand.
import path from 'node:path';
import { createMDX } from 'fumadocs-mdx/next';
const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
output: 'export',
trailingSlash: true,
images: { unoptimized: true },
reactStrictMode: true,
// workclock is linked from ../js, outside the project root.
turbopack: { root: path.join(import.meta.dirname, '..') },
};
export default withMDX(config);| Setting | Why | Source |
|---|---|---|
output: 'export' |
Writes the site to website/out/. |
Static exports |
trailingSlash: true |
Each page becomes docs/x/index.html, which GitHub Pages serves at /docs/x/. Check that /docs/x also reaches the page after the first deploy. |
trailingSlash |
images: { unoptimized: true } |
The default image loader needs a server. | Image: unoptimized |
turbopack.root |
Turbopack does not resolve files outside the project root; the file:../js link needs the root set to the repository. |
turbopack: root |
Not available in a static export, so not used: rewrites, redirects, custom headers, proxy.ts, Server Actions, ISR, route handlers that read the request (unsupported features).
The site is served from the root of https://workclock.karthik.nz/, so it sets no basePath. Links in llms.txt, .md files and social cards are absolute (https://workclock.karthik.nz/...), built from siteUrl in lib/shared.ts.
Set metadataBase: new URL('https://workclock.karthik.nz/') in app/layout.tsx so metadata image URLs are absolute.
// website/components/provider.tsx
'use client';
import { LucideProvider } from 'lucide-react';
import { RootProvider } from 'fumadocs-ui/provider/next';
import type { ReactNode } from 'react';
import SearchDialog from '@/components/search';
export function Provider({ children }: { children: ReactNode }) {
return (
<LucideProvider strokeWidth={1.5}>
<RootProvider search={{ SearchDialog }} theme={{ hotKey: false }}>
{children}
</RootProvider>
</LucideProvider>
);
}theme.hotKey: false turns off Fumadocs' single-key "d" theme shortcut (fumadocs-ui 16.17.8, provider/base.d.ts; Accessibility). app/layout.tsx wraps the site in Provider (Typography).
Fumadocs' built-in search has a static mode: a route handler writes the index to one file at build time, and the dialog downloads it on first use (static build, built-in search).
// website/app/api/search.json/route.ts
import { createFromSource } from 'fumadocs-core/search/server';
import { source } from '@/lib/source';
export const dynamic = 'force-static';
export const revalidate = false;
export const { staticGET: GET } = createFromSource(source, { language: 'english' });// website/components/search.tsx
'use client';
import {
SearchDialog, SearchDialogClose, SearchDialogContent, SearchDialogHeader,
SearchDialogIcon, SearchDialogInput, SearchDialogList, SearchDialogOverlay,
type SharedProps,
} from 'fumadocs-ui/components/dialog/search';
import { useStaticSearch } from 'fumadocs-core/search/client';
export default function StaticSearchDialog(props: SharedProps) {
const search = useStaticSearch({
from: '/api/search.json',
});
return (
<SearchDialog {...search} {...props}>
<SearchDialogOverlay />
<SearchDialogContent>
<SearchDialogHeader>
<SearchDialogIcon />
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
<SearchDialogList />
</SearchDialogContent>
</SearchDialog>
);
}Without from, the dialog fetches /api/search, which a static export does not write, so it returns 404 on GitHub Pages.
-
Site card:
app/opengraph-image.png, 1200 × 630, made in Canva and committed; its design also serves as the GitHub social preview (Brand). -
Per-page cards:
ImageResponsefromnext/oginapp/og/docs/[...slug]/route.tsxwithgenerateStaticParams, written to files at build time. Fumadocs' static example does this (examples/next-static/app/og, Fumadocs: next/og). Write the card's JSX for this site rather than using Fumadocs'generateOGImage, which has its own colours. Layout: Icons and Illustration. -
ImageResponsereads onlyttf,otfandwofffonts, supports flexbox but not grid, and has a 500 KB bundle limit (ImageResponse).next/fontserveswoff2, sowebsite/assets/fonts/InstrumentSans-SemiBold.ttfis committed withOFL.txt; the SIL OFL 1.1 allows redistribution with the licence.
Route handlers write llms.txt, llms-full.txt and one content.md per page at build time; what they contain and their URLs are on Agent Experience. For the build: page.data.getText('processed') returns Markdown only when the collection sets postprocess: { includeProcessedMarkdown: true } in defineDocs; llms().full() is used as it is, and the llms.txt route writes its own list with absolute .md URLs.
The demo, the playground and the static week strips run the workclock package built from this repository's js/ folder: "workclock": "file:../js" in website/package.json. The demo then matches the commit it is built from, and it works before 0.1.0 is on npm. Building js/ needs Rust, the wasm32-unknown-unknown target and wasm-pack (CONTRIBUTING.md), so CI and the deploy install them.
What the package offers (js/package.json, ADR 0009):
| Import | Build | Loads the wasm by |
|---|---|---|
workclock in Node.js |
wasm-pack nodejs
|
reading the file on import |
workclock in a browser bundle |
wasm-pack bundler
|
importing .wasm as a module, which the bundler must support |
workclock/web |
wasm-pack web
|
fetch after await init()
|
workclock/web, imported only on the pages that need it, inside a client component. init() is explicit, so the page controls when the 570 KB .wasm (local npm run build:web on 2026-10-24, uncompressed; it bundles the time zone database) is fetched, and docs pages never load it. The bundler entry would need Turbopack to handle a .wasm ES module import, which Next.js does not document.
// website/components/use-workclock.ts
'use client';
import { useEffect, useState } from 'react';
type Workclock = typeof import('workclock/web');
export function useWorkclock() {
const [workclock, setWorkclock] = useState<Workclock | null>(null);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
let cancelled = false;
import('workclock/web')
.then(async (mod) => {
await mod.default();
if (!cancelled) setWorkclock(mod);
})
.catch((e) => {
if (!cancelled) setError(e);
});
return () => {
cancelled = true;
};
}, []);
return { workclock, error };
}What the prototype found (2026-10-24, Next.js 16.4.3 with Turbopack, static export):
-
In the browser:
next buildcopiesworkclock_bg.wasmtoout/_next/static/media/and writes its URL (/_next/static/media/workclock_bg.<hash>.wasm), soawait init()with no arguments works. No copy step is needed. -
At build time: importing
workclock(the Node.js entry) in a server component fails: Turbopack rewrites__dirname, so the entry cannot find its.wasm, andserverExternalPackages: ['workclock']does not apply to a package linked withfile:../js.workclock/webworks when given the bytes:import { readFile } from 'node:fs/promises'; import path from 'node:path'; import init, { Schedule } from 'workclock/web'; await init({ module_or_path: await readFile( path.join(process.cwd(), 'node_modules/workclock/pkg/web/workclock_bg.wasm'), ), });
So the static week strips and the demo's first frame are computed at build time and are in the HTML before the
.wasmarrives. -
Still to check: the Accessibility checks on the landing page, once it exists.
A Schedule holds wasm memory: when the inputs change, free the old one (schedule.free(), or using; the class has [Symbol.dispose]).
A website job in ci.yml, added to the needs list of ci-ok, runs on every pull request without deploying, so a broken site fails the PR instead of the deploy. Steps:
- Install Rust with the wasm target and wasm-pack (the same pinned actions as the deploy below);
npm run build:webinjs/. -
npm ci,npm run types:checkandnpm run buildinwebsite/. - Run
npx playwright install --with-deps chromiumandnpx playwright test;playwright.config.tsservesout/at the site root withscripts/serve-out.mjsas itswebServer. - Run the lychee GitHub Action over the built HTML and
.mdfiles, with--root-dirset towebsite/outso root-relative links resolve.
- uses: lycheeverse/lychee-action@<sha> # <tag>
with:
args: >-
--no-progress
--root-dir ${{ github.workspace }}/website/out
'website/out/**/*.html' 'website/out/**/*.md'
fail: trueLook up the lychee action's latest release tag and SHA when adding it (gh api repos/lycheeverse/lychee-action/commits/<tag> --jq .sha).
.github/workflows/website.yml builds website/out/ and publishes it with GitHub's Pages actions (custom workflows). In the repository settings, Pages → Source must be "GitHub Actions"; the owner sets that.
It follows the repository's rules (ADR 0002, ci.yml): every action pinned by full SHA with the tag in a comment; permissions: contents: read at the top and more only where needed; persist-credentials: false; timeout-minutes on every job; zizmor and actionlint pass (the workflows job).
The SHAs for checkout, rust-toolchain, install-action and setup-node are the ones already in ci.yml. The Pages actions are new; look their SHAs up from the release tags (latest on 2026-10-24: configure-pages v6.0.0, upload-pages-artifact v5.0.0, deploy-pages v5.0.1):
gh api repos/actions/configure-pages/commits/v6.0.0 --jq .sha
gh api repos/actions/upload-pages-artifact/commits/v5.0.0 --jq .sha
gh api repos/actions/deploy-pages/commits/v5.0.1 --jq .shaname: Website
on:
push:
branches: [main]
paths:
- "website/**"
- "js/**"
- "crates/workclock/**"
- "crates/workclock-wasm/**"
- ".github/workflows/website.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: stable
targets: wasm32-unknown-unknown
- uses: taiki-e/install-action@a4d3f3f761dcd95cce1b72cb61cb6d1963438177 # v2.87.30
with:
tool: wasm-pack
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
package-manager-cache: false
- run: npm run build:web
working-directory: js
- run: npm ci
working-directory: website
- uses: actions/configure-pages@<sha> # v6.0.0
- run: npm run build
working-directory: website
- uses: actions/upload-pages-artifact@<sha> # v5.0.0
with:
path: website/out
deploy:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@<sha> # v5.0.1Notes:
-
pages: writeandid-token: writeare the minimum fordeploy-pages, given to the deploy job only. -
concurrency: pageswithcancel-in-progress: falselets a running deploy finish, as in Next.js' own template (nextjs/deploy-github-pages). - No
static_site_generatorinput onconfigure-pages: it would editnext.configin CI. - No npm cache, as in the release workflow's
npm-packagejob, so a poisoned cache cannot reach a deploy. -
upload-pages-artifactskips dot-files unlessinclude-hidden-files: true. Next.js writes assets to_next/, which starts with an underscore. GitHub's docs do not say whether an Actions deployment runs Jekyll (which ignores_folders); after the first deploy, check that a page loads its CSS from/_next/. If not, add an emptypublic/.nojekyllandinclude-hidden-files: true. - After the first deploy, check that a missing URL shows
404.html.
.github/ISSUE_TEMPLATE/docs.yml, named "Docs problem", label type:docs, with a text field id: page that the site's "Report a problem with this page" link fills from the URL (Components, Feedback link). Blank issues stay off in config.yml.
cd js
npm run build:web # writes js/pkg/web; needs Rust, wasm32-unknown-unknown and wasm-pack
cd ../website
npm ci
npm run dev # http://localhost:3000/
npm run build # static site in website/out/
npm run types:check # next typegen && tsc --noEmitRebuild js/ after any change to the Rust code or js/. To see the built site as GitHub Pages serves it:
npm run serve # http://localhost:4173/website/.gitignore: .next/, out/, .source/, next-env.d.ts, test-results/, playwright-report/.
{
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "node scripts/copy-markdown.mjs",
"types:check": "next typegen && tsc --noEmit",
"test:a11y": "playwright test"
}
}postbuild exists only if the owner picks the /docs/<slug>.md URLs (Agent Experience).
-
Exact versions. No
^or~inwebsite/package.json;.npmrchassave-exact=true;package-lock.jsonis committed and CI usesnpm ci. -
Move together. fumadocs-core and fumadocs-ui are always the same version. fumadocs-mdx has its own line (15.x) and needs
fumadocs-core ^16.17.8. fumadocs-ui 16 needsnext16. Upgrade them in one PR. -
Dependabot. An entry for
/websitematching the/jsentry in.github/dependabot.yml. The existinggithub-actionsentry covers the new workflows' SHAs.
- package-ecosystem: npm
directory: /website
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
website:
update-types: [minor, patch]
commit-message:
prefix: build
include: scope-
Node.js. CI uses 24;
website/package.jsonsets"engines": { "node": ">=22" }, likejs/package.json. -
Upgrades. Read the Fumadocs and Next.js release notes, build, open the site, and check search, the theme toggle, a
.mdfile and the demo before merging. Re-check the Fumadocs selectors listed on Design Tokens and Layout and Spacing.
None of these may be installed until the owner approves. Each has a way to do without it.
| Package | Kind | What it would do | Without it |
|---|---|---|---|
| fumadocs-typescript (5.6.0) | npm, dev | Generates the JavaScript API tables (AutoTypeTable) from js/pkg/web/workclock.d.ts. |
Hand-written TypeTables, checked in review. |
| fumadocs-python (1.0.5) | npm, plus a Python command | Generates the Python reference from docstrings. Marked "Experimental"; imports the package, so CI must build the wheel first. | Hand-written reference pages from the docstrings. |
cn |
npm | Joins class names, as in the example's lib/cn.ts. |
A template string or a three-line helper. |
postcss |
npm, dev | Listed directly in the Fumadocs example. @tailwindcss/postcss already depends on it. |
Leave it out unless the build asks for it. |
-
Proposed:
scripts/copy-markdown.mjs(Agent Experience). - The packages in "Proposed, needs approval".
workclock is MIT licensed. The repository documentation is authoritative; this wiki summarises it.