Chapter 11
Development
Everything for working on the code: setup, environment, commands, tests, and the rules that keep these docs true. Use pnpm only.
Setup
Node 22.22.3, 24.15 or 26 and newer (Nuxt's supported range) and pnpm.
pnpm install
cp .env.example .env
pnpm dev # http://localhost:3000 The site needs no configuration: the daily words are in-repo data and the season is worked out in the browser from the date. The data scripts (scripts/build-*.mjs) run through the data:* commands under plain node and need no credentials.
Environment
See .env.example. There is one optional variable; the site URL is read from runtimeConfig.public.
| Variable | Used for |
|---|---|
NUXT_PUBLIC_SITE_URL | Optional canonical origin for canonical and Open Graph URLs and the sitemap; otherwise each request's own origin. |
Commands
| Command | Description |
|---|---|
pnpm dev / build / start / preview | Dev server, production build, run the build, preview it. |
pnpm test / test:run / test:coverage | Vitest in watch mode, once, or with coverage. |
pnpm lint / format / type-check | ESLint and Prettier (both rewrite files: --fix, --write), and tsc --noEmit. |
pnpm test:e2e | Build, then run the Playwright browser tests in e2e/ in Chromium, Firefox and WebKit. |
pnpm test:e2e:dev | Run the app with nuxt dev and fail on a Vue warning (e2e-dev/). |
pnpm check-qa | Lint, format, type-check, build and test. |
pnpm docs:sync / docs:check | Fill, or verify, the README's generated regions. |
The data:* commands and pnpm assets:og-font are listed once, with what they read and write, in Adding and fixing words. A test fails if a script in package.json is in neither table.
Tests
Vitest, in the projects vitest.config.ts defines, and Playwright for the browser tests in e2e/.
test/unit(happy-dom): components and pages.test/setup.tsmocks#app(useRoute,useRouter, a workinguseAsyncData, no-opuseSeoMetaanduseHead); add to it and totest/mocks/app.tswhen a page needs another composable.test/server(node; no project name, so scope by path:pnpm exec vitest run test/server): API handlers, theshared/modules, the data scripts and the server utilities. The word endpoints need no mock because they read in-repo data; their tests freeze the clock withvi.useFakeTimers().test/content(fully offline): every entry and every word-list word against the committed snapshots (Data integrity).test/integration(node): builds Nuxt into a throwaway directory, starts the built server and requests every route over HTTP. It checks that the home page and a day's page hold their word in the HTML, that every part, kanji and chapter page answers, that a day that has not arrived is a404and the old links redirect, that the share image is a PNG, and that the cache header is on an answer and not on an error. It has no setup file, becausetest/setup.tsreplaces the globalfetchwith a mock. To run it alone:pnpm exec vitest run --project integration.e2e(Playwright; the desktop browsers and phonesplaywright.config.tslists):pnpm test:e2ebuilds the app, starts it on its own port and drives it in each. It checks that every page renders with no uncaught error or console error and cannot be scrolled sideways, that today's word opens from the home page, that the colour mode and season picks change the page and are remembered, that the Explore chips narrow the list and write the URL, that any/all agrees with the API, that the calendar moves between months and that a kanji page draws one frame per stroke. The music switch is driven too: it never starts by itself, asks for the season's track (and the next one when the season changes), and keeps the volume in the browser; the tracks are served as a short tone so no browser's codecs matter. Each season's falling or rising particles, glyphs and corner graphic are read back as the browser computes them, in light and dark, and are gone under reduced motion. Playwright needs its browsers (pnpm exec playwright install chromium firefox webkit) and the system libraries behind them; it is not part ofpnpm check-qa. The API is rate limited per address and every test comes from one, so keep the tests few.e2e-dev(Playwright, Chromium):pnpm test:e2e:devstartsnuxt devand visits every page, a word, a kanji and a part, and each season in dark mode, failing on any Vue warning, hydration mismatch, console error or uncaught exception. A production build prints none of these, so this is where a template naming something its script never defines is found. Thehints:hydrationnotes from@nuxt/hintsare not counted: they also flag what the app changes on purpose once mounted. The unit test for the docs chapters makes the same check without a browser.
The integration test builds the app, so pnpm test:run passing does prove it builds and serves; run pnpm build on its own only to see the build's output. CI (.github/workflows/webpage-test.yml; every job sets up through .github/actions/setup, which names the pnpm version once and takes Node's from engines in package.json) runs pnpm run test, pnpm test:e2e and pnpm test:e2e:dev as separate jobs on pushes to main and on pull requests targeting it.
Tests that guard against drift:
no-future-leak: nothing underapp/imports the entries.seasons-css-sync:shared/seasons.tsagainst the CSS.icons: everyi-heroicons-*name used has a path inapp/data/icons.ts.og-font: the share-image fonts can draw every word, reading and meaning, and nearly every headline.contrast: text on colour in every season and mode against WCAG AA (fills, shaded text and the muted grey of captions and labels, on the canvas, a card, a tint and a badge), that no class sets text in a 500 step or in a neutral step below AA, that the focus ring, the search box's border, a chip's border, the music switch's off track and a pressed chip's border reach 3:1 and that the classes shipping are the ones measured, and that text reaches AA under each seasonal backdrop wash.single-source: a fact owned by one module (Japan's offset, thelocalStoragekeys, the JSON envelope, the JLPT levels, the page chrome) is not written out again beside it.principles: the principles no other test sees. Nothing leaves the app (no other origin, script, frame, download, beacon or server write) and no reader data (no cookie, no storage but the namedlocalStoragekeys, no address read but by the rate limiter).server-only-modules: the import rule's list inshared/server-only.tsagainst the imports, so a new module that reads the entries cannot go unlisted.docs-sync: the docs against the code (see below), including the Architecture chapter's lists of modules, components, pages and redirects.docs-claims: the figures the docs quote against their source: the Node range against Nuxt's, the loudness inshared/audio.tsagainst ffmpeg's measure of the tracks (skipped without ffmpeg), the download sizes in Adding and fixing words against the pins, a chapter that lists the same backers as another, a link to the Roadmap while it is empty, and a word written twice.
Releases
A release is a git tag on main plus a GitHub Release; the tag is the only record of the version, so package.json carries none. The Releases page is the changelog, and there is no CHANGELOG.md.
- Versions are SemVer, read for a product. Major: what the app is, or something readers could use, changed or was removed. Minor: new features or data. Patch: fixes only.
- Notes are written by theme, starting from
gh release create --generate-notesand trimmed to Features, Data, Docs and Maintenance. Dependency bumps become one Maintenance line. - A major opens with a Breaking or Pivot line saying what was removed and which earlier tag still has it. The tags are the archive, not long-lived branches.
- Published tags are never moved or deleted. Fix a mistake in the notes with
gh release edit. - Run
pnpm check-qaon the commit being tagged.
Keeping the docs true
These docs are the pages under app/pages/docs/. There is no separate copy in the repo, so a chapter is the only place its topic is written.
- Add a chapter by writing
app/pages/docs/<slug>.vuearound<DocsBook slug="…">and listing it inshared/docs.ts. Title, summary, path, contents, previous and next links, the home page and the sitemap all follow from that one entry. Figures areDocDiagramspecs: boxes on a grid joined by arrows, drawn as inline SVG in the season's colours. - One owner per topic. A chapter either owns a topic or links to the one that does: Core theme owns the principles; Architecture the layout, the import rule and the single sources of truth; Daily words the entry, dates and derived pages; API the endpoints; Data integrity the evidence and checks; Adding and fixing words the procedures; this chapter setup, commands and tests.
- Facts live in code. Attribution is
shared/sources.ts, routes areshared/endpoints.ts, seasons areshared/seasons.ts, the limits areshared/limits.ts, the Node range and framework versions arepackage.json, and the word range comes fromdata/words/(GET /api/catalogue). The pages read them. Never type a source URL, a licence name, a route list, a word count, a version, a limit or a size into a chapter or the README. When a chapter needs a fact, in this order: read it from the module that owns it (move the constant intoshared/first if it is private, and usecountWord()for a count in words); generate it where the page is Markdown, with apnpm docs:syncregion; and only when the owner cannot be imported (a pin in a script, a measurement of a file) type it once and add a case totest/server/docs-claims.test.tsthat checks it against the owner. pnpm docs:syncfills the generated regions of the README (the attribution, the Node range, the stack line, the JLPT levels, the vocabulary layers and the chapter titles, between<!-- docs:begin … -->markers, never edited by hand).pnpm docs:checkfails if it is stale.- Change a behaviour, change its chapter in the same commit.
test/server/docs-sync.test.tscatches a hand-typed range or count, a source URL or licence, a route that doesn't exist, apnpmcommand that isn't a script, a path in<code>that isn't in the repo, a chapter list that disagrees with the page files, a script, environment variable or composable the book does not mention. - Wrap every command, path and identifier in
<code>(or backticks in the strings a table renders throughRichText), never bare text.