Project Structure
Develi has one organising idea, and almost every directory decision follows from it: a page is a thin route shell that owns its layout and its SEO, and composes sections that own markup. Once you know that sentence you can predict where a given piece of code lives.
The tree
src/
├── assets/ # optimizable images (astro:assets), in seven folders
├── components/
│ ├── Cards/ # 7 content-aware card compositions
│ ├── Sections/ # layout-free page sections, per page + Global/
│ ├── svg/icons/ # the 572-icon registry and <Icon>
│ └── ui/ # 46 UI primitives + 11 shared internal modules
├── config/ # typed site config — the single source of truth
│ ├── siteData.json.ts
│ ├── siteSettings.json.ts
│ ├── navData.json.ts
│ ├── legalData.json.ts
│ └── types/configDataTypes.ts
├── data/ # content collection entries
│ ├── authors/<slug>/index.md
│ ├── blog/<slug>/index.md
│ └── work/<slug>/index.md
├── js/ # read helpers: blog, work, nav, rotate, schema, textUtils
├── layouts/ # BaseLayout (shell) + BaseHead (<head>)
├── pages/ # 16 route files → 29 built pages
├── styles/ # global.css entry, tailwind-theme.css, fonts.css, motion/
└── content.config.ts # the three Zod collection schemas
Alongside it, scripts/ holds exactly one file — the test runner — and public/ holds three: two favicons and the placeholder OG image. Everything else that ships is under src/.
The three component tiers
This is the part worth understanding properly, because putting a file in the wrong tier is the most common way to make the theme harder to work in than it needs to be.
src/pages/ — routes. A route owns BaseLayout, the page title and description, noindex, any per-page JSON-LD, and the composition order of its sections. It resolves collection data and passes it down as typed props. It contains almost no markup. The home page is 46 lines, most of which is a comment explaining why the sections appear in that order.
src/components/Sections/ — layout-free content blocks. A section is a <section> and its contents. It never imports BaseLayout. It either receives its content as typed props from the route or reads config for itself — never both for the same data. Sections live under a folder named for their page (Home/, About/, Services/, Contact/, Work/, Blog/, Legal/, NotFound/, UiCatalog/), and move to Global/ the moment a second page uses them.
src/components/ui/ — primitives. Generic, content-unaware, one folder each, built on tailwind-variants. A primitive knows about a size and a variant; it does not know what a blog post is. The contract is in src/components/ui/README.md and is covered in UI Components.
src/components/Cards/ — the bridge. A card is a composition built from the ui/card primitives that does know about a data shape — PostCard takes a CollectionEntry<"blog">. Seven ship: CaseCard, NumberedCard, PostCard, ServiceCard, SupportCard, TeamCard, TestimonialCard.
The promotion rule is stated in the codebase and worth adopting: a section moves to
Global/the day a second page uses it, and section-local sample data moves tosrc/config/the day a second surface reads it.navData.json.ts’sservicesarray exists precisely because the footer and the services page had already disagreed about what the studio offers.
What is in Global/
Sixteen files, and their presence tells you which parts of the design repeat: Header, HeaderNav, HeaderMenu, Footer, FooterReveal, FooterNewsletter, CallToAction, Process, Metrics, Testimonials, ScrollTimeline, SectionHeading, SectionSeam, BackdropArt, HeroBackdrop, PhotoPairHero.
Two of those are structural rather than content:
SectionSeamdraws the notch that joins two page bands, painted in the colour of the block on the other side of the join. The design hangs it off five section boundaries. It ships one path set and flips it with-scale-y-100rather than carrying two — an earlier version carried both plus a path parser to verify they were mirrors, which they were.SectionHeadingis the shared heading-plus-lede block, on its sixth call site. It owns the--rv-delaythat staggers the lede behind the heading, which is the drift it was extracted to end.
The config layer
Four typed files under src/config/, with their interfaces in types/configDataTypes.ts. Nothing in a component hard-codes a brand string, a route or a feature flag; it imports one of these.
The most important structural detail is that several values are derived rather than repeated. siteData.sameAs is navData.social.map(s => s.href). The footer’s Services column is services.map(({ slug, label }) => …). The blog’s route prefix is headerHref("Blog") rather than the literal /blog/. Each of those replaced a value that was typed twice and had already drifted. Configuration walks through all four files.
Path aliases
Defined in tsconfig.json and used everywhere in preference to deep relative imports:
| Alias | Resolves to |
|---|---|
@config/* |
src/config/* |
@js/* |
src/js/* |
@layouts/* |
src/layouts/* |
@components/* |
src/components/* |
@assets/* |
src/assets/* |
@images/* |
src/assets/images/* |
@/* |
src/* |
ESLint’s simple-import-sort orders them for you: side-effect imports first, then alphabetized. Run pnpm format and stop thinking about it.
Relative imports are still correct in one case — a sub-part importing its sibling. Header.astro imports ./HeaderNav.astro, because they are one unit and an alias would obscure that.
src/js/ — the read layer
Six modules, and the split between them encodes a rule that is easy to miss:
blog.tsandwork.tsare the query layers for their collections. A route never callsgetCollectionand sorts inline. Both importastro:content.schema.tsholds the JSON-LD builders.textUtils.tsholdsslugify,initialsandformatDate.nav.tsanswers one question — is this link the current page? — and is deliberately dependency-free, becausenav.test.tsruns under bare Node, which does not resolve path aliases. A single config import there would fail the self-check with a module-resolution error instead of a useful assertion. That is whyheaderHreflives innavData.json.tsrather than here.rotate.tsholds the wrap-around arithmetic behind the related-cases rail, for the same reason:work.tscannot be tested under bare Node, so the one piece of logic in it that could be silently wrong was moved somewhere that can be.
That pattern recurs throughout the theme. Logic that could be wrong without anything visibly breaking gets moved into a module that imports nothing, next to a *.test.ts that checks it. The pagination ellipsis window lives in ui/pagination/window.ts for the same reason, and the carousel’s loop arithmetic in ui/carousel/scroll.ts.
src/styles/
Five files. global.css is the single entry point, imported once by BaseLayout, and it imports the other four in a deliberate order: fonts, then Tailwind, then the token file, then the motion catalog, then an explicit @layer theme, base, components, utilities;.
The token architecture spans two files by design — tailwind-theme.css holds the palette aliases and the @theme inline bridge, global.css holds the semantic runtime variables that flip between :root and .dark. They are separate so the token file can also be imported into an .astro <style> block. Colors covers the whole system.
Where to put a new file
- A new page →
src/pages/, plus a folder underSections/for its sections. - A section used by one page →
Sections/<Page>/. Used by two →Sections/Global/. - A generic, content-unaware widget →
src/components/ui/<name>/, following the primitive contract. - The same widget, but it knows about a collection entry →
src/components/Cards/. - A value referenced by two surfaces →
src/config/. - A query that two routes both need →
src/js/. - Pure logic that could be silently wrong → its own module, with a
*.test.tsbeside it.