Chapter 3
Architecture
NipponDaily is a Nuxt 4 app (Vue 3, TypeScript, pnpm) with a small Nitro API. The words are JSON in the repo, served by date and never before their day arrives; the pages are rendered on the server, so the HTML a crawler gets already holds the word.
Overview
- The entries are in-repo data:
data/words/YYYY-MM.json, one entry per day, no gap between the first day and the last, running May 2005 to February 2027 (7959 words). Only the headline is hand-written; everything else is generated from sources (Data integrity). - A day is open once midnight in Japan has passed. The API, the sitemap and every derived page read only open days.
- The server stores nothing. The words are in-repo data and the season is worked out from the date in the browser, so the app needs no datastore and no configuration.
- Nothing about a reader is stored by the app. No accounts, and no request to a third party: the fonts are served from the app. The server sees a request only to answer it. The rate limiter counts addresses in process memory only, 60 seconds at a time, and writes them nowhere.
Layout
app/ pages/, components/, composables/, utils/, data/, assets/css/tailwind.css
shared/ code imported by both app/ and server/ (the ~~/shared alias); not auto-imported
server/ api/ (handlers), routes/ (sitemap, robots, share images), middleware/ (rate limit), plugins/ (caching), utils/, assets/og/ (share-image fonts)
scripts/ data builders run with node: reference snapshots, etymology pins, example sentences, pitch accents, kanji records, strokes, kanji shapes, entry generator
data/ word-plan/ (hand-written), words/ (generated), reference/ (generated evidence)
types/ shared TypeScript shapes (index.ts)
test/ the Vitest projects (see Development)
e2e/ Playwright browser tests of the built app
e2e-dev/ Playwright run against nuxt dev, failing on a Vue warningThe shared modules
| Module | Role |
|---|---|
words.tsserver and tests only | The catalogue (WORD_ENTRIES), JST date logic (todayJst…), entryForDate, lapEntryForDate, payloadFor, calendarForMonth. |
parts.tsserver and tests only | partsIndex(today) and partDetail(text, today): the morpheme index, derived from the entries' morphemes. |
kanji.tsserver and tests only | kanjiIndex(today) and kanjiDetail(char, today): KANJIDIC2's record of each kanji an open word is written with (data/reference/kanji.json), its KanjiVG strokes where the counts agree (data/reference/strokes.json), the shapes KRADFILE lists it as containing (data/reference/components.json) and the open words that use it. |
explore.tsserver and tests only | exploreWords(filters, today, limit?): search and filters (several choices, any or all) with facet counts, listing only the newest limit matches. exploreCalendar(month, filters, today): one calendar month marked against the same filters, with per-month counts. |
related.tsserver and tests only | relatedWords(entry, today): the open words most like one entry. referencesFor(entry, today): where the words JMdict points its sense at stand among the open words. |
patterns.tsserver and tests only | patternsFor(today): counts across the open words, including rendaku. |
sitemap.tsserver and tests only | sitemapXml() and robotsTxt(): open days only. |
og-card.ts | ogCard(entry, glyphs): the share image of one word as a tree satori draws, in its day's season. Takes the entry as an argument, so it is data-free. |
catalogue.ts | The written range and count, computed from entry dates. Data-free. |
explore-query.ts | Reads and writes the Explore filters as a URL query (comma-joined lists). Data-free. |
word-labels.ts | WORD_STRATA, WORD_PROCESSES, POS_GROUPS and FREQUENCY_GROUPS labels and definitions, posGroupsOf(), frequencyOf() and isHedged(). Data-free. |
meanings.ts | servedVocab(): the corrections and enrichments applied to a word-list entry (with the row overrides in scripts/lib/word-list.mjs). |
seasons.ts | The seasonal presets: SEASONS, SEASON_IDS, DEFAULT_SEASON, seasonForDate(). |
sources.ts | Data sources, licences and credit lines. |
routes.ts | ROUTES and API_PREFIX: every path the server answers, for the composables, the rate limiter, the cache rule and robots.txt. |
endpoints.ts | What each route returns, the rate limit and the CDN cache rules. |
docs.ts | The chapters of this book. |
server-only.ts | The import rule: SERVER_ONLY_MODULES, the modules app/ may not import because they read every entry. A test derives the list from the imports and fails when it is wrong. |
japanese.ts | What counts as a kanji (KANJI_CHARS, kanjiIn(), hasKanji()) and katakanaToHiragana(), for the kanji records, the search and the data scripts. |
jlpt.ts | JLPT_LEVELS, JLPT_RANGE and the names of the level snapshots (referenceFile()). |
stack.ts | stackOf(): the Node range and framework versions, read from package.json. |
count.ts | countWord() and andList(): a count (“four”) and a list of names as prose says them, so a chapter can say how many, and which, without typing them. |
audio.ts | The music's loudness and crossfade: BGM_LOUDNESS_LUFS, BGM_CROSSFADE_MS. |
limits.ts | The numbers the rules turn on: MAX_PART_LENGTH, MAX_QUERY_LENGTH, the Explore page size, the related-words limit, weight and minimum score, MAX_EXAMPLES, STROKE_SQUARE and the Patterns combination sizes and example limit. |
jst.ts | Japan's calendar: JST_OFFSET_MS, DAY_MS, toJst(). |
storage-keys.ts | STORAGE_KEYS, the only keys in a reader's localStorage. |
The app
- Pages:
/(today's word),/words(month calendar),/words/<date>,/explore,/patterns,/parts,/parts/<text>,/kanji,/kanji/<character>,/kana,/docs/*(this book) and a catch-all that answers a real404and isnoindex. Old links to/game,/learn/**and/vocab/**redirect to/(routeRulesinnuxt.config.ts). - Composables are
useAsyncData-based, so the server renders with data.useApiDatais the one place that fetches, turns a failure into a message and, on the server, into a404;useDailyWord,useWordCalendar,usePartsIndex,usePart,useKanjiIndex,useKanji,useExplore,usePatternsanduseCatalogueare each a call to it.useRelatedWordsalone fetches by itself, because its failure must never make the page a404. Besides these areusePageSeo(withuseSiteUrl, the canonical origin),useSiteTheme,useBgmanduseDropdown(the open state of the header's season and music panels). - Components:
WordEntryView(one entry, with its pitch accent),KanjiStrokes(a kanji's strokes, one frame each),RelatedWords,WordReferences(the words JMdict points a sense at),SoundAlikes(the words read alike, with their accents),SameDayWords(the words of the same day in other years),WordFilters(the filter form shared by Explore and the calendar),MonthPicker(the calendar's year and month jump),AppHeader,AppFooter,AppShell(the header, backdrop and footer every page sits in),DocsBook(a docs page),DocDiagram,SeasonButton,BgmControl,SeasonalEffects,TrendingFallback(the generic fetch-error card), the education charms of/kana(OmamoriCharmandEmaPlaque), the book's own parts (DocsContents,CatalogueRange,RichTextfor the light markup in tables,ColorSwatchBadgeandErrorStateHeading), and small localU*.vueprimitives that mimic the@nuxt/uiAPI (the project does not depend on it). - SEO: every page calls
usePageSeo(title, description, canonical, Open Graph,noindexfor error states). URLs are absolute:NUXT_PUBLIC_SITE_URL, else the request origin. - SSR errors: a server fetch that fails with
400/404makes the HTTP response a404. A skeleton shows only while a client-side fetch is pending;TrendingFallbackshows on failure (all the states).
Caching
The site has no per-reader state, so every successful page and API answer is the same for everyone until the next midnight in Japan, when the day's word opens. server/plugins/day-cache.ts marks each successful GET with s-maxage set to the seconds left until then (server/utils/day-cache.ts), so a CDN serves it without running the app and the copy expires at the moment a new word opens. It sets no stale-while-revalidate, which would show yesterday's word after the day had turned, and it never marks an error. The exception is the share image, which never changes once its day has opened and so keeps longer; its rule is in shared/endpoints.ts and the API chapter. The season is applied in the browser, never in the HTML, which is why a cached page is safe. Without a CDN that honours s-maxage every request runs the app, which is correct but slower.
This saves invocations, not computation: with every entry in memory a patterns, parts or explore answer takes a few milliseconds, so no in-process cache is kept.
Conventions
- No service layer. The server holds no state of its own and calls nothing outside the process: the daily words are in-repo data, so handlers read them through
shared/. - Configuration is one optional setting, the public site URL, read from
runtimeConfig.publicbysiteOrigin()inserver/utils/site-url.ts. The data scripts run under plainnodeand need no credentials. - Types live in
types/index.ts. - State is local (
ref,computed); there is no global store. Onlycolor-theme,season-choice,bgm-volumeever sit in a reader'slocalStorage. - One implementation. Before writing a second copy of a handler, composable, page shell or script step, use or extend the helper that already does it:
ok,notFoundandparseQueryfor API answers (server/utils/),useApiDatafor fetching,AppShellfor a page's header, backdrop and footer, andscripts/lib/for the data scripts. A number or name the rules turn on goes in theshared/module that owns it (see below).test/server/single-source.test.tsfails when one of those facts is typed again beside its owner. - Package manager is pnpm only.
Single sources of truth
A fact is written once, in code, and everything else reads it. Prose that explains something stays hand-written, once per audience, and points here instead of restating the fact. A number, a version, a count, a size or a list of names in prose is a fact, so it is generated, not typed (the Development chapter says how). The rule (often called DRY) applies in these places:
- Code. One implementation per job, and a constant the rules turn on lives in the
shared/module that owns it (see One implementation above). - Data. Entries in
data/words/anddata/reference/are generated, never edited by hand. A wrong value is fixed in its source or in the parser, then regenerated (Adding and fixing words), and a test fails when a committed entry differs from what the generator derives (Data integrity). - Facts the pages state. Sources, routes, seasons, the word range, the Node range and framework versions, the limits and the music's loudness are read from code or data when the page renders, or when
pnpm docs:syncfills the README, as the table below shows. A figure whose owner cannot be imported, such as a download size pinned in a script, is typed once and checked against that owner by a test. - Docs. One chapter owns each topic and the others link to it (Development).
| Fact | Written once in | Read by |
|---|---|---|
| Data sources, licences, credit lines, the revision permalink | shared/sources.ts | The footer, each entry's citation, Data integrity, the README |
| HTTP routes: the paths | shared/routes.ts | The composables, the rate limiter, the cache rule, robots.txt, the share-image link and shared/endpoints.ts |
| HTTP routes: what each returns | shared/endpoints.ts | The API chapter |
| Word range and count | data/words/ | GET /api/catalogue (via shared/catalogue.ts), which every chapter that quotes the range reads |
| Season palettes, months, motifs | shared/seasons.ts | The CSS (a test keeps it in step), Seasons, Colour and shape |
| Japan's calendar: the offset and the length of a day | shared/jst.ts | todayJst(), seasonForDate(), the cache lifetime in server/utils/day-cache.ts |
| The rate limit and the CDN cache rules | shared/endpoints.ts | The rate limiter, nuxt.config.ts, the share-image route, the API and Seasons chapters |
| The numbers the rules turn on (related words, examples, part length) | shared/limits.ts | The code that applies them, the tests and the Daily words chapter |
| The `localStorage` keys | shared/storage-keys.ts | The buttons and composables that write them, the inline script in nuxt.config.ts, the docs |
| The Node range and the framework versions | package.json (read through `shared/stack.ts`) | The Architecture and Development chapters, the README |
| The music's loudness and crossfade | shared/audio.ts | The player, the Seasons chapter, the test that measures the tracks |
| The JLPT levels | shared/jlpt.ts | The Explore filters, the data scripts, every chapter that names the range |
| Chapters, titles, summaries, order | shared/docs.ts | The contents, each chapter's heading and SEO, the home page, the sitemap |
To add a data source, add it to shared/sources.ts; it then appears in Data integrity and the README. The footer names only the two sources whose licence calls for a visible credit, so add a line there by hand if a new one needs it. To add an endpoint, add the handler under server/api/ (or server/routes/), its path in shared/routes.ts and an entry in shared/endpoints.ts; a test fails until both exist. How the docs are kept in step is in Development.