|
| 1 | +# Architecture — the design criteria behind astro-builder |
| 2 | + |
| 3 | +Every structural decision in this plugin — in the sites it generates and in the plugin itself — |
| 4 | +is justified by the criteria in John Ousterhout's *A Philosophy of Software Design* (APoSD). |
| 5 | +This document makes those criteria explicit so that future decisions can be argued from them, |
| 6 | +not from taste. Two audiences: |
| 7 | + |
| 8 | +1. **Code the builder generates** — what a well-designed astro-builder site looks like and why. |
| 9 | +2. **The plugin itself** — why the audit, skills, and scaffolds are shaped the way they are. |
| 10 | + |
| 11 | +The unit of judgment throughout is the **module**: anything with an interface (what callers must |
| 12 | +know) and an implementation (what it hides). A layout, a `lib/` file, a skill, the audit — all |
| 13 | +modules. |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +## Part 1 — The code the builder generates |
| 18 | + |
| 19 | +### 1.1 — Deep modules: small interface, large functionality |
| 20 | + |
| 21 | +A module's cost to its callers is its interface; its value is the functionality behind it. A |
| 22 | +**deep** module gives a lot for a little. A **shallow** module makes you learn an interface |
| 23 | +nearly as big as the implementation it fronts. |
| 24 | + |
| 25 | +The canonical deep module in every generated site is the **BaseLayout `<head>`** |
| 26 | +(`docs/init-templates/BaseLayout.astro.template`, rules in `skills/seo-conventions/SKILL.md`). |
| 27 | +Its entire interface is five props: |
| 28 | + |
| 29 | +```ts |
| 30 | +interface Props { |
| 31 | + title: string; |
| 32 | + description: string; |
| 33 | + image?: string; |
| 34 | + type?: "website" | "article"; |
| 35 | + publishedAt?: Date; |
| 36 | +} |
| 37 | +``` |
| 38 | + |
| 39 | +Behind those five props: the full title format, the canonical URL, the complete OG/Twitter set, |
| 40 | +JSON-LD (`WebSite` / `Article`), hreflang alternates including `x-default`, and the RSS and |
| 41 | +sitemap `<link>`s — all derived from `Astro.site`, `Astro.url`, `Astro.currentLocale`, and the |
| 42 | +i18n config. |
| 43 | + |
| 44 | +```astro |
| 45 | +<!-- 🔴 Shallow: the interface restates the implementation — every page must know |
| 46 | + everything the layout knows, and each page can get it differently wrong --> |
| 47 | +<BaseLayout |
| 48 | + title={title} |
| 49 | + canonical={`https://example.com/en/articles/${slug}`} |
| 50 | + ogTags={{ "og:title": title, "og:url": `https://example.com/en/articles/${slug}`, ... }} |
| 51 | + alternates={[{ lang: "en", href: "..." }, { lang: "it", href: "..." }]} |
| 52 | + jsonLd={{ "@type": "Article", headline: title, ... }} |
| 53 | +> |
| 54 | +``` |
| 55 | + |
| 56 | +```astro |
| 57 | +<!-- ✅ Deep: pages state the five facts only they know; the layout derives the rest --> |
| 58 | +<BaseLayout title={title} description={description} type="article" publishedAt={date}> |
| 59 | +``` |
| 60 | + |
| 61 | +The test: **does the caller pass anything the module could derive?** If yes, the interface is |
| 62 | +leaking implementation. The five props are exactly the facts only the page knows. |
| 63 | + |
| 64 | +### 1.2 — Narrow interfaces pull complexity downward |
| 65 | + |
| 66 | +When complexity must exist, put it inside the module so callers don't carry it. The generated |
| 67 | +`src/lib/` layer is built on this: |
| 68 | + |
| 69 | +- **`lib/i18n.ts`** — `createTranslator(Astro.currentLocale)` returns `tl(key)`. Nothing else |
| 70 | + knows where strings live, the fallback order, or the default locale. The `MessageKey` union |
| 71 | + turns a typo into a compile error instead of a blank string in production. |
| 72 | +- **`lib/urls.ts`** — `buildLocaleUrl(locale, ...segments)` is the one place that knows every |
| 73 | + route carries a locale prefix and a trailing slash. Components never concatenate paths. |
| 74 | + |
| 75 | +```astro |
| 76 | +<!-- 🔴 Every component re-derives routing policy; change the URL scheme, sweep the codebase --> |
| 77 | +<a href={`/${Astro.currentLocale}/articles/${article.id.replace(`${Astro.currentLocale}/`, "")}/`}> |
| 78 | +
|
| 79 | +<!-- ✅ Policy lives once, behind one function --> |
| 80 | +<a href={buildArticleUrl(article.id, locale)}> |
| 81 | +``` |
| 82 | + |
| 83 | +The complexity (locale prefixes, slug stripping, trailing slashes) didn't disappear — it moved |
| 84 | +down, where it is written once and tested once. |
| 85 | + |
| 86 | +### 1.3 — Information hiding: one decision, one place |
| 87 | + |
| 88 | +Each design decision should live in exactly one module; everything else consumes its result. |
| 89 | +In generated sites: `prefixDefaultLocale: true` is *known* only by `lib/urls.ts` (building |
| 90 | +prefixes) and BaseLayout's `localePath` strip (removing the one the framework reports). No |
| 91 | +component parses a URL to detect the locale — that would copy the routing decision into every |
| 92 | +parser. |
| 93 | + |
| 94 | +The same principle shapes this plugin: **a convention rule and its mechanical check live |
| 95 | +together.** Each convention skill defines its rules in `SKILL.md` and ships their checks in |
| 96 | +`references/audit.md`, side by side in one directory. Changing a rule is a one-directory edit. |
| 97 | +If the audit owned the checks, every rule change would be a two-place edit that someone forgets |
| 98 | +half of — the classic information leak. |
| 99 | + |
| 100 | +### 1.4 — When NOT to create an abstraction |
| 101 | + |
| 102 | +Depth cuts both ways: an abstraction that adds interface without adding functionality is pure |
| 103 | +cost. |
| 104 | + |
| 105 | +- **One adapter is a hypothetical seam.** Don't introduce an interface/port until at least two |
| 106 | + real implementations exist (e.g. production + test). With one, the "seam" is just |
| 107 | + indirection — collapse it. |
| 108 | + |
| 109 | + ```ts |
| 110 | + // 🔴 A ContentSource interface with exactly one implementation, "in case we |
| 111 | + // swap the CMS later" — callers now learn two surfaces to reach one behavior |
| 112 | + interface ContentSource { getArticles(lang: string): Promise<Article[]> } |
| 113 | + class CollectionContentSource implements ContentSource { ... } |
| 114 | + |
| 115 | + // ✅ The concrete function, until a second source is real |
| 116 | + export async function getArticlesByLang(lang: string): Promise<Article[]> { ... } |
| 117 | + ``` |
| 118 | + |
| 119 | +- **Passthrough wrappers are shallow by definition.** A function that forwards to another |
| 120 | + function with the same signature gives callers nothing. |
| 121 | + |
| 122 | + ```ts |
| 123 | + // 🔴 New surface, zero new functionality |
| 124 | + export function getTranslation(locale: string, key: MessageKey): string { |
| 125 | + return createTranslator(locale)(key); |
| 126 | + } |
| 127 | + ``` |
| 128 | + |
| 129 | +- **Real plugin example: there is no `new-component` skill.** Writing a component is already |
| 130 | + fully governed by `css-conventions`, `html-conventions`, and `ux-writing` — they auto-apply. |
| 131 | + A `new-component` skill would restate their rules behind a second entry point: a shallow |
| 132 | + wrapper over three deep modules. The decision *not* to build it was an architecture decision. |
| 133 | + |
| 134 | +### 1.5 — Design it twice |
| 135 | + |
| 136 | +Your first interface is unlikely to be the best one. Before fixing any interface — a `lib/` |
| 137 | +function, a layout prop contract, a new skill — sketch **at least two radically different** |
| 138 | +designs and compare them by depth (leverage per entry point), locality (where future change |
| 139 | +concentrates), and what callers must know. |
| 140 | + |
| 141 | +The BaseLayout contract went through exactly this. Alternatives considered: |
| 142 | + |
| 143 | +| Design | Verdict | |
| 144 | +|---|---| |
| 145 | +| Per-tag props (`canonical`, `ogImage`, `alternates`, ...) | Shallow — interface grows with every SEO feature; callers restate derivable facts | |
| 146 | +| One `seo: SeoConfig` object prop | Same width, hidden in a bag — and invites pages to construct policy | |
| 147 | +| **Five scalar props, everything else derived** | Deep — interface only grows when a page learns a genuinely new fact | |
| 148 | + |
| 149 | +Write the comparison down (a few lines in the plan is enough). If the second design never beats |
| 150 | +the first, you at least know *why* the first is right. |
| 151 | + |
| 152 | +### 1.6 — Comments are part of the design |
| 153 | + |
| 154 | +A comment earns its place by saying what the code *cannot* say: intent, invariants, the reason a |
| 155 | +derivation is shaped the way it is. The generated templates practice this: |
| 156 | + |
| 157 | +```ts |
| 158 | +// ✅ States an invariant the signature can't express (from urls.ts): |
| 159 | +/** RSS feed for a locale: `/en/rss.xml`. No trailing slash — it is a file, not a page. */ |
| 160 | + |
| 161 | +// ✅ States the design rule and its boundary (from BaseLayout): |
| 162 | +// Hand-written SEO tags anywhere else are a violation. |
| 163 | +``` |
| 164 | + |
| 165 | +```ts |
| 166 | +// 🔴 Restates the code — noise that will drift |
| 167 | +// loop over the locales and create a link for each |
| 168 | +locales.map((locale) => ...) |
| 169 | +``` |
| 170 | + |
| 171 | +If you can't write a comment that adds information, the abstraction may not need one — or the |
| 172 | +abstraction itself may be too thin to describe. |
| 173 | + |
| 174 | +--- |
| 175 | + |
| 176 | +## Part 2 — Evolving the plugin |
| 177 | + |
| 178 | +The plugin is judged by the same criteria as the code it generates. |
| 179 | + |
| 180 | +### 2.1 — The audit is a thin orchestrator |
| 181 | + |
| 182 | +`skills/audit/SKILL.md` defines the report contract, the run order, and the checks that have no |
| 183 | +backing skill (architecture, i18n, schema, build). It never restates a convention rule. Each |
| 184 | +domain's rules live in its skill's `SKILL.md`; the mechanical checks live in that skill's |
| 185 | +`references/audit.md`; the audit reads a table and runs what it finds. |
| 186 | + |
| 187 | +**The cost target is the design's acceptance test: adding a domain to the audit costs one |
| 188 | +row in one table.** When the css-conventions skill shipped, the audit gained one line. If a |
| 189 | +future change makes a new domain cost more than that, the orchestrator has started absorbing |
| 190 | +knowledge that belongs in a skill — that is the regression to catch in review. |
| 191 | + |
| 192 | +### 2.2 — Skills are deep modules |
| 193 | + |
| 194 | +A skill's frontmatter `description` is its interface — it must carry everything the trigger |
| 195 | +decision needs, because it is all the model sees before loading the body. The body is the |
| 196 | +implementation: rules, rationale, good/bad contrasts, verification list. The same shallow-module |
| 197 | +smell applies: a skill whose body merely restates other skills (see the `new-component` |
| 198 | +non-decision, §1.4) should not exist. |
| 199 | + |
| 200 | +### 2.3 — Checklist for any structural change |
| 201 | + |
| 202 | +Before adding a skill, agent, scaffold template, or audit domain: |
| 203 | + |
| 204 | +- [ ] Is the new module deep — interface meaningfully smaller than what it hides? |
| 205 | +- [ ] Does each piece of knowledge it introduces live in exactly one place? |
| 206 | +- [ ] Did you sketch at least two interface designs and record why the winner won? |
| 207 | +- [ ] If it wraps something that exists, what functionality does the wrapper *add*? |
| 208 | +- [ ] Does "add the next instance of this" still cost one line / one file / one row? |
| 209 | + |
| 210 | +--- |
| 211 | + |
| 212 | +## Attribution |
| 213 | + |
| 214 | +The concepts here — deep vs. shallow modules, information hiding, pulling complexity downward, |
| 215 | +design it twice, comments as design — are from **John Ousterhout, *A Philosophy of Software |
| 216 | +Design*** (Yaknyam Press). This document adapts them to the astro-builder domain; read the book |
| 217 | +for the full argument. |
| 218 | + |
| 219 | +The seam/adapter framing in §1.4 and the compare-by-depth/locality process in §1.5 are adapted |
| 220 | +from the `improve-codebase-architecture` skill (`DEEPENING.md`, `INTERFACE-DESIGN.md`) in |
| 221 | +[mattpocock/skills](https://github.com/mattpocock/skills) (MIT). See `NOTICE.md` at the plugin |
| 222 | +root. |
0 commit comments