Project Structure
TVfolio’s directory layout encodes one rule: a page is a thin route shell, and everything below it has a contract. A route owns BaseLayout and its SEO props, and composes sections. A section is a layout-free block of content. A primitive is a token-only piece of UI. Nothing reaches past its neighbour.
src/
├── components/
│ ├── Sections/<Page>/ layout-free page sections; Global/ for cross-page ones
│ ├── Cards/ content-aware card compositions
│ ├── ui/<name>/ the 40-primitive library (see its README for the contract)
│ └── svg/icons/ the <Icon> system and its 572-icon registry
├── config/ typed site config — the source of truth, never literals in components
├── data/<collection>/ content collections, Zod-validated
├── js/ TypeScript utilities + their *.test.ts checks
├── layouts/ BaseLayout + BaseHead (all meta/SEO tags live here)
├── pages/ file routes — thin shells owning BaseLayout + SEO
└── styles/ global.css entry, tailwind-theme.css tokens, motion/ catalog
Three of those folders carry a written contract as a README.md beside the code: Sections/README.md, Cards/README.md and ui/README.md. They are the theme’s own house rules, and they are worth reading before you add anything to those folders — the section contract in particular explains the one rule most people break.
The four layers
Pages (src/pages/) are route shells. A page owns BaseLayout, the title / description / noindex props, and any JSON-LD it passes through — and then composes sections. A page never contains layout markup for content it did not author, and a section never imports BaseLayout. Look at src/pages/privacy.astro: it is fourteen lines, and eleven of them are the import block and the BaseLayout call.
Sections (src/components/Sections/) are layout-free blocks — a hero, a project briefing, a legal article. There are 33 of them outside the dev catalog, and 21 live in Global/, which is the largest folder because the television itself lives there: TvSet, TvControlPanel, TvScreenBar, FastextBar and PagesDropup are sections shared by every route that draws a screen. The promotion rule is explicit and unusually early: a section used by two pages moves to Global/ at the second caller, not the third.
Cards (src/components/Cards/) are content-aware compositions — a card that knows it is showing a blog entry, as opposed to a generic ui/card. The folder currently holds only its contract; the TV design draws its cards inside the tube’s own idiom instead.
Primitives (src/components/ui/) are the 40-folder library. Each is one folder with a tailwind-variants recipe, an index.ts re-export, tokens only and no raw colours. UI Components covers the contract and the deliberate decision that only three of the forty reach a real page.
The config layer
src/config/ is the source of truth for everything that is a fact rather than a sentence:
| File | Owns |
|---|---|
siteData.json.ts |
brand name, title, description, the author block, availability facts, sameAs, the social targets on the cabinet, the default social image |
siteSettings.json.ts |
siteLang / siteLocale plus the useViewTransitions and useAnimations switches |
navData.json.ts |
the six fastext keys, the eleven channels, and the helpers that join them |
cvData.json.ts |
the career record — careers, education, toolkit |
legalData.json.ts |
the terms and privacy copy, section by section |
types/configDataTypes.ts |
the types every one of the above is checked against |
deployEnv.mjs |
the shared “is this a production deploy?” predicate |
The rule the theme states repeatedly is that a component reads config or receives props, never both for the same data. siteData.json.ts goes one step further and exports availabilityLabels, the four drawn forms of the availability facts, because more than one page prints each — deriving them at the call site was a real bug, where /about/ and /cv/ each wrote their own From ${from} and the contact form re-derived “48 hours” independently of the spec strip that promised it.
deployEnv.mjs is the one .mjs file in a TypeScript project, and deliberately so: both astro.config.mjs and siteData.json.ts import the same predicate rather than restating it, and the config file cannot import TypeScript.
The owned utilities
src/js/ holds the parts of the theme that would normally be dependencies:
| Module | Job |
|---|---|
schema.ts |
dependency-free JSON-LD builders — Organization, WebSite, Article, CreativeWork, Person, BreadcrumbList |
cv.ts |
the CV record, its plain-text renderer, and a byte-exact PDF writer |
rss.ts |
the RSS 2.0 builder behind /rss.xml |
contact.ts |
the contact form’s pure half — parse, validate, rate-limit, build the email body |
blog.ts / work.ts |
the single ordered list each collection is read through |
textUtils.ts |
shared string and number helpers, including reading time |
Seven of the twelve check files in the repo sit beside these modules. That is the pattern: a pure function that can break silently gets one runnable check in the same folder, and pnpm test discovers it without registration.
blog.ts and work.ts deserve a note, because they are two files of about fifteen lines each that carry a lot of weight. Each exports exactly one function returning the collection as one ordered list, and every surface reads that list — the index, the detail pages, the home page’s recent rows, the RSS feed. A bulletin’s number is its position in getBulletins(); a transmission’s number is its position in getWork(). Because there is one list, no two pages can number, order or filter an entry differently, and growing the meaning of “what is on air” means editing one function rather than every caller.
Path aliases
tsconfig.json defines six aliases, and imports across the theme use them consistently:
"paths": {
"@config/*": ["./src/config/*"],
"@js/*": ["./src/js/*"],
"@layouts/*": ["./src/layouts/*"],
"@components/*": ["./src/components/*"],
"@images/*": ["./src/assets/images/*"],
"@/*": ["./src/*"]
}
There is no baseUrl, which is a deliberate call rather than an omission — since TypeScript 4.1 paths resolve relative to the config file’s own directory, and baseUrl is deprecated. The values carry an explicit ./ prefix to stay unambiguous.
One caveat worth knowing before it bites you: modules that the Node test runner reaches — @js/cv, @js/contact, @js/schema and their imports — use relative paths with explicit .ts extensions rather than aliases, because plain Node resolves neither aliases nor extensionless imports. The same files use import type rather than import { type … } for the same reason: the inline form survives type stripping as a runtime import and breaks the check.
Styles
src/styles/ holds five files and one folder, and the layering is worth internalizing before you retheme anything:
global.css— the single entry point, imported once byBaseLayout. It orders the layers, holds the semantic:root/.darkvariables, and imports everything else.tailwind-theme.css— the palette aliases, the fixed cabinet materials, and the@theme inlinebridge.tube-type.css— the CRT’s own type scale, 14 steps.motion/index.css+motion/keyframes.css— the 89-utility motion catalog.fonts.css— the@font-faceblocks._css.ts— a tiny CSS reader the contrast and theme-parity checks use to assert on the stylesheets themselves.
That last one is unusual and worth calling out: two of the theme’s twelve checks parse the real CSS files and assert on the values they find, so a token that stops flipping between themes, or a colour pair that drops below WCAG AA, fails pnpm test rather than shipping. Colors & Theming explains what they pin.