Project Structure
Medice has one organising idea, and almost every directory decision follows from it: a route is a thin shell that owns its URL and its SEO, and delegates its markup to sections, which compose cards and UI primitives. Once you know that sentence you can predict where a given piece of code lives, and — more usefully — where a new piece belongs.
There are sixteen route files and they build 52 pages. No route file contains layout.
The tree
src/
├── assets/images/ photographs, imported not referenced
├── components/
│ ├── Sections/
│ │ ├── Global/ sections drawn by 2+ page groups (Header, Footer, CtaBand, …)
│ │ ├── <Page>/ sections of one page group (Home, About, Book, Doctors, …)
│ │ └── UiCatalog/ the dev-only primitive showroom — delete before launch
│ ├── Cards/ content-aware card compositions (Clinician, Post, Step, Topic)
│ ├── ui/<name>/ the 37 primitives — lowercase folder, PascalCase file
│ └── svg/icons/ <Icon> + the 599-entry merged registry
├── config/ typed site content — the source of truth, never literals in components
│ ├── doctors/ the nine clinician profiles, one file each
│ ├── services/ the nine specialty detail pages, one file each
│ ├── careers/ the fourteen role adverts
│ └── types/ the interfaces those files satisfy
├── data/blog/ the health library — one folder per article, Zod-validated
├── js/ helpers: schema, blog, booking, textUtils, numbers, tv
├── layouts/ BaseLayout + BaseHead (every meta and SEO tag lives here)
├── pages/ file routes — thin shells owning BaseLayout + SEO
└── styles/ global.css entry, tailwind-theme.css tokens, motion/ catalog
The three component tiers
The split is not decorative — each tier has a contract file in the repository, and the contracts are what keep the tiers from blurring.
ui/ primitives are the bottom tier: unopinionated, content-free, built on tailwind-variants, and styled entirely through semantic tokens so a rebrand reaches them for free. A primitive knows nothing about clinics. Its contract is src/components/ui/README.md, and it is five rules: one folder per primitive, typed props that are native attributes plus variant props, an exported tv() config, tokens rather than raw colors, and consumer overrides merged so the last conflicting utility wins. See Components.
Cards/ sit between: ClinicianCard, PostCard, StepCard and TopicCard are content-aware compositions that know what a clinician or an article is, built from primitives. They exist because the same clinician card is drawn on the homepage, the directory and three service pages, and a card that knows its own shape is one place to fix rather than five.
Sections/ are the top tier: layout-free blocks of page content — a hero, a feature grid, a legal article. A section never imports BaseLayout. Its contract is src/components/Sections/README.md.
Global versus page-specific
The unit is a page group, not a page. A section drawn by both /careers/ and /careers/<role>/ still belongs to Careers/, because those are two routes of one group. It moves to Global/ when a second group draws it.
There is a good test for a mistake: if a section in Global/ imports from a <Page>/ folder, it is in the wrong place — that import is the section telling you which group owns it. And a promoted section brings nothing with it. When a section moves to Global/, its page-data import goes too, and both callers pass their own strings as props. A Global/ section still reading one page’s config is only half promoted; CtaBand, DetailHero, ProsePanel, ChecklistPanel and FeaturePanel each needed that correction.
Sub-parts sit beside their section and are imported relatively. Shared non-component code inside a section folder takes a _ prefix — _booking.ts, _estimate.ts, _listing.ts, _ground.ts — the same convention as ui/_client.ts. That underscore is doing real work: it marks a file the section folder holds but that is not itself a section.
Why so many files are _-prefixed and split in two
You will notice pairs: _listing.ts beside _listingFilter.ts, _booking.ts beside _estimate.ts, blogData.json.ts beside src/js/blog.ts. The split is always along the same line, and it is worth understanding because it explains a lot of the layout.
pnpm test runs each check as a plain Node module through --experimental-strip-types. Node cannot load a module that imports astro:content, astro:env/server or the DOM. So the logic worth checking is deliberately separated from the imports that would make it unloadable: the pure half gets a *.test.ts beside it, and the impure half stays thin enough not to need one. src/js/booking.ts has no imports at all, which is exactly why the booking validator is testable.
This is also why src/config/siteImages.ts exists. Config data files cannot import their own photographs — Node resolves neither the @images/* alias nor a .jpg — so the data files carry image keys and one map resolves them. A key with no file, or a file with no key, fails astro check rather than rendering a blank space.
The config layer
src/config/ is the source of truth for site content, and the rule is that a component never carries a literal a config file could own. Each page group has a <page>Data.json.ts file, and most have a <page>Data.test.ts beside it asserting the things two files could disagree about.
The larger collections break out into folders: nine clinician profiles in config/doctors/, nine specialty detail pages in config/services/, fourteen role adverts in config/careers/. configDataTypes.ts under config/types/ holds the interfaces they all satisfy.
Cross-file consistency is enforced rather than hoped for. The homepage draws six specialties with the identical title, description and icon the services index draws, so homeData reads them from servicesData rather than restating six entries in a second file that could drift. The booking form’s visit types, clinicians and insurers are read from the price list, the doctor roster and the pricing band for the same reason. See Configuration.
Path aliases
Declared in tsconfig.json, resolved by both Astro and TypeScript:
| Alias | Points at |
|---|---|
@config/* |
src/config/* |
@js/* |
src/js/* |
@layouts/* |
src/layouts/* |
@components/* |
src/components/* |
@images/* |
src/assets/images/* |
@/* |
src/* |
There is no baseUrl: since TypeScript 4.1 paths resolve relative to the config file’s own directory, and baseUrl is deprecated. Path values are prefixed ./ to stay explicitly relative.
One wrinkle worth knowing: .astro files import extensionlessly (@config/siteData.json), but any module that is also loaded by a plain-Node check writes the extension explicitly (./navData.json.ts), because Node does not guess extensions. If you add a config file that a *.test.ts imports, follow the explicit form.
Where a new file goes
- A page →
src/pages/, thin:BaseLayout, SEO props, and a list of sections. - A block of one page →
src/components/Sections/<Page>/. - A block two page groups draw →
src/components/Sections/Global/, with its data passed in as props. - A content-aware card →
src/components/Cards/. - A reusable, content-free control →
src/components/ui/<name>/, following the five-rule contract. - Copy, lists and structured content →
src/config/, never a literal in a component. - A pure helper two routes need →
src/js/, with a*.test.tsbeside it. - Section-local shared code →
_prefixed.tsin the section’s own folder.