The starting point: a form, a site
The requirement fits in one sentence: someone fills in a form — business name, three or four services, a tone, maybe a logo, two photos, opening hours — and gets back a complete static folder, ready to serve: index.html, legal notice, privacy policy, assets/, robots.txt, sitemap.xml.
In between sits a model. The question is not whether an LLM can write a landing page — it can, and that is precisely the problem. The question is: what exactly do we let it write?
The naive first answer: “generate the HTML”
Asking the model for the whole page works on the first attempt and degrades on every one after that. Three symptoms keep coming back:
- The output is never the same twice. Two nearly identical briefs produce two different page structures, two typographic scales, two spacing systems. No product identity is possible.
- Quality is capped by the worst sample. A 3.1:1 text/background contrast in one case out of twenty is enough to make the result unusable, and nothing catches it: it is valid HTML.
- Nothing can be fixed globally. An improvement to the grid or the palette should benefit every site already generated. If each site is a unique artefact produced by a model, there is nothing to fix — everything has to be regenerated, and hoped for.
The conclusion is unremarkable but structural: what we want from the model is not code. It is editorial judgement — which visual direction suits a bike repair shop, in which order to present the services, which headline lands — and text. The rest belongs to the code.
The stance: the model picks, the code renders
Hence the inversion. The model produces no HTML, no CSS, no colour. It produces a composition: a JSON object that names, inside a closed catalog, what the site should contain.
The catalog lives in the code and exists exactly once:
- 10 visual directions — each a set of tokens (two embedded woff2 font families, an accent palette, CSS foundations) validated once and for all, WCAG contrast included.
- 11 section types —
hero,facts,services,steps,about,gallery,testimonial,hours,values,cta-band,contact— each with its layout variants. - 40 SVG icons and a handful of named decorative graphics.
The model picks a direction, an accent, a sequence of sections with their variants, and writes the copy. A rendered hero will always be a hero rendered by the same template, with the same margins, the same typographic scale and a contrast that has already been measured.
An important corollary: the prompt describing the catalog is generated from the catalog code, not maintained alongside it. A section type added to the code shows up in the prompt on the next call; there is no world in which the model knows a variant the renderer does not.
Stage 1: a draft that cannot fail
First temptation: hand the model the strict schema directly, as structured output, and let the provider guarantee conformance. Bad idea, for a very concrete reason.
The 11 section types do not share the same fields. A hero has a headline, a subheadline, two button labels, a graphic; a gallery has captions; a contact has a privacy note. Expressing that as structured output means a union of 11 objects — and a deep union, with different required fields per branch, is exactly the kind of schema on which a model produces formally valid but semantically approximate answers: filling a field with an empty string rather than omitting it, or filing a caption under the wrong type.
The draft takes the opposite stance: everything is optional, an empty string means absent.
class SectionDraft(BaseModel):
type: str
variant: str
title: str = ""
intro: str = ""
headline: str = ""
subheadline: str = ""
cta_primary: str = ""
graphic: str = ""
paragraphs: list[str] = Field(default_factory=list)
captions: list[str] = Field(default_factory=list)
items: list[ItemDraft] = Field(default_factory=list)
One flat object for all 11 types, no required field beyond type and variant. The model can barely produce a response that structured output rejects. This is not laxity: it is a refusal to conflate two very different failures — “the model malformed its JSON” and “the model composed an invalid site”. The first should never happen again; the second is interesting and deserves a message.
Stage 2: the strict gate
The draft is then converted into the real shape, and that is where everything happens. Discriminated union on type, Literal types built from the catalog, lengths bounded field by field:
DirectionName = Literal[*DIRECTION_NAMES]
IconName = Literal[*ICONS]
class ServiceItem(BaseModel):
name: S40 # 1 to 40 characters
text: S160
icon: IconName # a catalog name, not an invention
tag: S24 | None = None
class ServicesSection(SectionBase):
type: Literal["services"]
variant: Literal[*variant_names("services")]
title: S60
items: list[ServiceItem] = Field(min_length=3, max_length=6)
The length bounds are not cosmetic. A 60-character title fits the layout; a 140-character title breaks the grid on mobile. Typing becomes the specification of the template here: if it validates, it renders correctly — that is the whole value of a closed catalog.
Same logic for fields that come in pairs. A badge in the hero has a label and a value; one without the other makes no sense:
if (self.badge_label is None) != (self.badge_value is None):
raise ValueError("badge : badge_label et badge_value vont ensemble, les deux ou aucun")
disponible · imposé · comptéavailable · forced · counted
pas de photo, pas de galerie ; pas de témoignage, pas de section témoignageno photo, no gallery; no testimonial, no testimonial section
directions · sections · variantes · icônesdirections · sections · variants · icons
le catalogue est engendré depuis le code : une seule source de véritéthe catalog is generated from the code: a single source of truth
tout optionnel, vide = absentall optional, empty = absent
absorbe les approximations du modèle sans casser la sortie structuréeabsorbs the model's approximations without breaking structured output
union discriminée + bornes + règlesdiscriminated union + bounds + rules
ce que le schéma ne dit pas — ordre, unicité, services du brief — est validé à la mainwhat the schema cannot say — order, uniqueness, brief services — is checked by hand
une ligne = un problèmeone line = one problem
les messages de refus repartent tels quels au modèle ; second échec = erreurthe rejection messages go back to the model as-is; a second failure is an error
une fois, pas deuxonce, not twiceWhat the schema cannot say
Some rules cannot be expressed in any reasonable type system, because they constrain the sequence of sections rather than each one:
if not 5 <= len(types) <= 8:
problems.append(f"5 à 8 sections attendues, {len(types)} reçues")
if types and types[0] != "hero":
problems.append("la première section doit être un hero")
if types and types[-1] != "contact":
problems.append("la dernière section doit être le contact")
if types.count("services") != 1:
problems.append("exactement une section services attendue")
for t in SECTION_NAMES:
if types.count(t) > 1:
problems.append(f"le type {t} apparaît au plus une fois")
if len(types) >= 2 and types[-2] == "cta-band":
problems.append("cta-band ne peut pas être en avant-dernière position, juste avant le contact")
The last one is the most interesting: nothing technically forbids a call-to-action band right before the contact form, but the result is visually redundant — two invitations to act glued together. It is a design rule, enforced by the code, invisible to the model until the moment it breaks it.
The other family of rules depends on the brief, not on the composition. It is carried by a context computed upfront:
@dataclass(frozen=True)
class CompositionContext:
services: tuple[str, ...] # noms des services du brief, dans l'ordre
available: frozenset[str] # types de section disponibles pour ce brief
forced_direction: str | None
brand_ok: bool # kit de marque avec extraction réussie
photo_count: int
Three checks follow, and the third is the most important in the system:
- No photo in the brief, no
gallerysection; no testimonial supplied, notestimonialsection. The model cannot invent material that does not exist. - Exactly as many captions as photos.
- The brief’s services are all reused, in order, with the same names. The comparison runs on normalised names (whitespace collapsed, case folded), and any deviation is a rejection:
got = [_squash(i.name) for i in s.items][:n]
if got != [_squash(x) for x in ctx.services]:
messages.append("services du brief à reprendre tous, dans l'ordre, avec les mêmes noms : "
+ ", ".join(ctx.services))
Without that rule, the model has a marked tendency to “improve”: renaming “Full service” into “Full service and adjustments”, adding a plausible fourth service the actual business does not offer. That is exactly the class of error you do not want to discover in production, on someone’s site.
The error path: one line, one problem
On rejection, the messages go back to the model for a single retry. Two implementation details separate a useful retry from a decorative one.
First, the messages are written for the model, not for a log: “5 to 8 sections expected, 9 received” is actionable, ValidationError at sections.8 is not. They are in French because the brief and the expected copy are — no reason to make the model switch language mid-conversation.
Second, one problem per line. Aggregated validation errors arrive grouped in a single ValueError separated by newlines, and are split again on the way out:
lines = msg.split("\n")
messages.append(f"{loc} : {lines[0]}" if loc else lines[0])
messages.extend(lines[1:])
The model therefore receives a list of distinct grievances, not a wall of text. On this workload the retry succeeds on the second attempt in the large majority of observed rejections — the typical error is one section too many or a reworded service, not a deep misunderstanding.
One retry, and that is it. A second failure marks the generation as failed, visible in the interface. The temptation to loop “until it passes” is strong and wrong: it turns a prompt bug into a token bill, and hides exactly the signal you want to see. The worst case is bounded by construction: two calls, output capped at 10,000 tokens each.
A detail that is not one: never repair silently
One unwritten rule runs through the whole system: nothing is silently corrected between the draft and the strict shape. No truncating an over-long title, no dropping the extra section, no substituting a default icon for an unknown one.
It is tempting — a title[:60] and the problem disappears. Mostly it disappears from view: the site ships with a title cut mid-word, and nobody will ever know the model misread the instruction. An explicit rejection, handed back to the model, produces a better site and an actionable signal about the prompt.
The only concession runs the other way: the “empty string → absent” normalisation at the draft boundary, which does not repair an error but encodes a convention.
Lessons learned
Five habits worth reusing whenever a model feeds a deterministic system:
- Two schema stages beat one. A permissive contract at the model boundary, a strict contract at the domain entrance. That separates “malformed answer” (to eliminate) from “invalid answer” (to exploit).
- The catalog generates the prompt. Every list the model must respect — types, variants, icon names — is derived from the code that implements it. Two lists that look alike always end up diverging.
- Type bounds are the template specification. A maximum length is not a nicety: it is the promise that whatever validates will render correctly.
- Validation errors are a prompt. Written for a reader, one line per problem, in the language of the task — far more useful than a generic restatement of the instructions.
- One retry, never a silent repair. The infinite loop is expensive and lies; the quiet fix lies too, for longer.
What is left to the model, in the end, fits in two words: pick and write. It is not much, and it is exactly what it does best.