Imported from fbuireu/biancafiore (
src/application/AGENTS.md). Install upstream withnpx skills add fbuireu/biancafiore --skill application. Copyright stays with the author.
src/application
The anti-corruption layer between Contentful and the domain. dto/ maps and entities/ loads. See ADR 0012 for the layering, and ADR 0002 for why the content is in Contentful at all: this folder is what keeps that choice reversible.
dto/ (Contentful → domain)
dto/<concept>/
<concept>DTO.ts # export const xDTO: BaseDTO<RawX[], XDTO[]> = { create }
types.ts # XSkeleton (EntrySkeletonType) + RawX = Entry<XSkeleton, undefined>
utils/ # mapping helpers owned by this concept
index.ts # re-exports the DTO only
A DTO fed by more than one query takes them as a tuple, not as separate arguments: authorDTO maps [RawAuthor[], RawArticle[]], which is exactly what the loader's fetchEntries hands back.
- DTOs are pure on purpose. They take already-fetched raw entries and return domain DTOs. No
getEntries, no Effect, no env, noawaiton I/O. Everycreateis synchronous, and deterministic:formatDatepinstimeZone: "UTC"so a publish date does not slide a day backwards when the build runs west of UTC. This is what makes them trivially unit-testable. The line is I/O, not layering:@infrastructure/images/imageOptimizationis imported here (getOptimizedImageUrlandgetOptimizedSrcsetonly build a CDN URL string), whilegetImagePlaceholders, which fetches, is not. - Contentful types stop here.
EntryFieldTypes,EntrySkeletonType,documentToHtmlStringmay appear in this folder and nowhere downstream. The domain never sees asysor afields. - One raw entry, one mapping.
createAuthorindto/author/utils/author.tsis the only place a raw author entry becomes Author fields:authorDTOspreads it and adds the article references, andarticleDTOcalls it for the Byline it embeds. Autils/helper normally serves the concept that owns it, and this is the exception: it lives under the concept it maps, not under the one borrowing it, because two hand-copied field lists are what let a Byline and an Author Tag disagree. Whitespace the CMS kept is trimmed at this boundary too, so the slug a Byline links to and the slug/tags/[slug]is generated from are the same string by construction. A comparison downstream that has to.trim()before it matches is a sign a mapping was skipped. An unresolved link is refused here, by itssys.id, because Contentful answers a link rather than an entry whenever the target is unpublished or the include depth is exhausted, and the type is a union that says so: readingfieldsoff the link half used to throwCannot read properties of undefined, naming neither the author nor the article that carried it. Every other reader of that union already narrowed on"fields" in entry; this one cast instead. - One raw entry, one address.
articleSlugindto/article/utils/reference.tsis the only place a raw Article entry becomes the string that identifies it, andarticleReferencebeside it is the only place one becomes aReference<"articles">, so no{ collection: "articles" }literal is written anywhere else in this folder, andarticleDTOtakes its ownslugfrom the same call. That is what makes the id the articles collection is keyed on and every reference aimed at it equal by construction rather than by separate builders happening to agree: the Tag Index, the Author's Articles and an Article's Related Articles all derive from one normalisation. It matters because the consumer,resolveArticle/resolveArticlesin@modules/core/utils/entries, drops a reference it cannot find without a word, so a slug Contentful padded used to delete an Article from its own tag pages and empty the Author's Latest Article, at build time, silently. - One concept, one decision.
createRelatedArticlesindto/article/utils/articles.tsis the only module that answers what an Article's Related Articles are. It owns the branch CONTEXT.md defines (hand-picked, else inferred from shared Tags), soarticleDTOhands it the whole batch and takes the answer rather than restating the rule with a ternary. It excludes the Article from its own list in both branches, and byarticleSlug, the same identity every other reference here is built from: an editor who files A under A's ownrelatedArticlesgets no card linking back to the page being read, and two Articles that merely share a title stay two Articles that can suggest each other. The cap,INFERRED_RELATED_ARTICLES_LIMIT(6), applies to the inferred branch only: an Author who picks eight means eight. - Derivations delegate to
@domain/<concept>/rules. The DTO decides which raw field feeds a rule; the rule decides what the value means. Don't inline reading-time maths or description trimming here. - An asset URL is absolutised here, by
createImageindto/shared/images.ts, the one module that knows Contentful serves//images.ctfassets.net/….imageSchema.urlis therefore an absolute URL and says so, and no page, component or JSON-LD builder re-adds the scheme by hand; a template that does is a leak of exactly the transport detail this layer exists to absorb. The rich-text renderer indto/article/utils/content.tsstill prefixes its own embedded-asset URLs, because those come off the raw Contentful node and never reachcreateImage. - An internal link is a route, not a string. The rich-text renderer in
dto/article/utils/content.tslinks an embedded Article witharticleHrefand recognises a tag page withisTagPath, both from@const/routes.ts, so no hand-written/articles/survives in this layer. The canonical-domain test next to them (the base URL an authored link is resolved against to decide whether it is external) is deliberately not the site originabsoluteUrluses: it asks whether the editor typed a link to the production domain, which is the same question in every environment, preview included. - Optional CMS fields get their defaults at this boundary, so the domain DTO is total:
?? falsefor the editorial flags an author may simply not have ticked,?? documentToHtmlString(rawArticle.fields.content)for a description Contentful never received; the body is rendered a second time only for the articles that need it, because??never evaluates its right side when a blurb exists. Every default written here is asserted against the DTO layer, so removing one from the code fails the docs test.
entities/ (Astro content collections)
entities/<plural>/
<plural>.ts # defineCollection({ loader, schema })
index.ts
The loader is the only place content I/O happens. Most of them are the same steps with a few holes punched in them, so they share cmsCollection in entities/collection.ts and pass what varies: the query, the mapper, the image field, and how an entry is identified. tags and authors keep hand-written loaders because they fetch more than one content type. The steps, whether a loader composes them or writes them out:
fetchEntries<[Skeleton, …]>(query, …): one query per array of raw entries it answers with, in the order they were written. That call is the whole of what a loader knows about the CMS: no Effect, noCmsClient, no runtime, and no credential guard; no page cursor either, so no loader carries alimitto buy completeness. Missing credentials answer an empty array per query rather than failing, so builds work without them; several queries run concurrently because the interface batches them, not because the loader asked; and every matching entry comes back becausefetchEntriespages until the collection is exhausted. Alimithere is therefore an editorial decision (this page wants the first five), never a guess at how much content exists- map with the concept's DTO, apply domain ordering rules (
sortFavoriteFirst) - return entries carrying an
id, whatever is unique for that concept, not automatically the slug:articles→slug,tags→slug,authors→slug,cities→name,testimonials→author,projects→id(the oneprojectDTOalready derives fromfields.id, falling back to a slugified name). Each pair is asserted against its loader, so the sentence cannot name a key the code does not use.authorskeyed on the name until an audit readCONTEXT.mdbeside it: one Author is one Slug there, the name is a display label, and two Authors sharing one collapsed into a single entry.citieskeeps the name because a City’s slug isslugify(name), so the two are one identity by construction, andtestimonialskeeps the quoted person’s name because a Testimonial has no other identifier and the glossary never promises that one is unique: two quotes from the same person would collapse, which is a cost taken deliberately rather than an id invented for the CMS. See ADR 0019. The spread comes before the key, never after. It used to read{ id: article.slug, ...article }in several loaders, so a DTO that grew anidof its own would silently have overridden the computed one, which is exactly the positionprojectswas in and why it opted out of the map entirely.cmsCollectionwrites it once, the right way round, and takes the choice asidentify schemaalways comes from@domain/<concept>, never redeclared here. Extending it is the exception:authorsaddsreference()fields, which belong at this layer precisely because collection names are not a domain concern
A collection stores what identifies an entry, never how one page arranges it. The tags loader emits one flat entry per addressable Slug, ordered Article references and all; the A–Z bucketing the tag listing renders is a groupBy call in pages/tags/index.astro. It used to be the stored shape, and both tag routes opened by flattening it back: an arrangement in storage is work every other reader has to undo.
Post-processing that fetches (getImagePlaceholders) happens in the loader, after the DTO, not inside it. Rewriting an image URL does not fetch, which is why the rich-text renderer in dto/article/utils/content.ts may do it inline.
A loader hands that step every source at once and maps over the answer; it never awaits one entry at a time. getImagePlaceholders takes the URLs and answers a Map keyed by the source it was given, so how many requests are open at once belongs to that module rather than to whichever collection happens to be the largest. The shape it replaced (Promise.all(entries.map(async (entry) => ({ ...entry, placeholder: await … }))), written the same way in every loader) made the burst as wide as the collection, and a request the asset CDN dropped came back as undefined, indistinguishable from an entry with no image. A source missing from the Map still reads as "no placeholder", so placeholders.get(url) stays the whole of what a loader does with it.
Testing a loader. A loader is reachable from Vitest. entities.test.ts holds every loader to step 1 (they all answer with no entries and ask Contentful for nothing without credentials, and they all query once they have them), and articles and authors additionally have their own files covering mapping, ordering and post-processing; the rest do not yet. It costs two vi.mock calls per file: one for astro:content, and one that spreads the real @infrastructure/cms/client but swaps CmsClientLive for the stub layer in src/tests/doubles/cmsLayer.ts. Substitute the layer, never fetchEntries: the point is to keep the real runtime, the real batching and the real isContentfulConfigured in the test and replace only the network. What fetchEntries promises on its own (one array per query, in order, concurrently, empty without credentials, and complete however many pages that takes) is asserted once, in cms/entries.test.ts, where cmsServesPagesOf makes the double answer a total larger than the items it serves. What the test cannot check is the schema: reference() has no stand-in, so no entry is ever parsed. ADR 0016 records both halves.
Adding a content type
domain concept (schema/types/rules) → dto/<concept> → entities/<plural> → register the collection in src/content.config.ts. Add the glossary term to CONTEXT.md in the same change.