Chapter 06Tutorials
Workshop: port an existing HTML site
A real starting site — three pages, one stylesheet, one script, some images — ported file by file. The same design at the end, but editable by the client.
How to read this workshop
Same convention as the previous workshop: every code block carries, in its banner, the exact path of the file and what to do with it — create it, replace it entirely, or change a single line. The “Check” boxes say what you must see before continuing.
If you have already done the previous workshop, half the gestures will look familiar. If not, this one still works on its own: nothing is assumed.
The starting site
Café Bergamote, a static site hand-written three years ago. It works, it ranks well, and its owner simply wants to fix the opening hours without calling anyone.
index.html
carte.html
contact.html
assets/
css/style.css
js/main.js
img/
devanture.jpg
salle.jpg
torrefaction.jpgSkip this section and transpose: what follows only depends on the shape of the starting site — a repeated header and footer, a central block that changes, a stylesheet, a script.
Here is the home page, in its original state:
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Café Bergamote — torréfaction artisanale à Lyon</title>
<meta name="description" content="Café de spécialité torréfié sur place, rue Sainte-Catherine à Lyon.">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header class="entete">
<a class="marque" href="index.html">Café Bergamote</a>
<button class="burger" aria-expanded="false" aria-controls="menu">Menu</button>
<nav class="menu" id="menu">
<a href="index.html">Accueil</a>
<a href="carte.html">La carte</a>
<a href="contact.html">Nous trouver</a>
</nav>
</header>
<main>
<section class="hero">
<h1>Torréfié sur place, tous les mardis</h1>
<p class="accroche">Café de spécialité, rue Sainte-Catherine depuis 2019.</p>
</section>
<section>
<img src="assets/img/devanture.jpg" alt="La devanture du café, un matin d'hiver"
width="1600" height="900">
</section>
<section class="propos">
<h2>Notre torréfaction</h2>
<p>
Nous achetons en direct producteur et torréfions <strong>en petites séries</strong>,
le mardi matin. Le café part en boutique le jour même.
<a href="carte.html">Voir la carte</a>.
</p>
</section>
<section>
<h2>Ce qu'on en dit</h2>
<ul class="liste-avis">
<li>
<blockquote>Le meilleur espresso du quartier, sans discussion.</blockquote>
<cite>Claire D.</cite>
</li>
<li>
<blockquote>On y vient pour le café, on y reste pour l'accueil.</blockquote>
<cite>Malik T.</cite>
</li>
</ul>
</section>
</main>
<footer class="pied">
<p>12 rue Sainte-Catherine, 69001 Lyon — 04 78 00 00 00</p>
<p>© <span id="annee"></span> Café Bergamote</p>
</footer>
<script src="assets/js/main.js"></script>
</body>
</html>
The other two pages are built the same way: same header, same footer, same script — and a
different <main>. That repetition is what will become a layout.
<main>
<section class="hero hero-court">
<h1>La carte</h1>
<p class="accroche">Torréfaction du mardi, mise à jour chaque semaine.</p>
</section>
<section class="cafes">
<div class="cafe">
<img src="assets/img/torrefaction.jpg" alt="Grains en cours de refroidissement"
width="1600" height="900">
<h2>Éthiopie Sidamo</h2>
<p class="origine">Lavé — notes de bergamote et d'abricot</p>
<p>Notre café signature, celui qui a donné son nom à la maison.</p>
</div>
<div class="cafe">
<img src="assets/img/salle.jpg" alt="La salle du café en fin de journée"
width="1600" height="900">
<h2>Colombie Huila</h2>
<p class="origine">Lavé — chocolat noir et noisette</p>
<p>Le plus rond de la carte, parfait en filtre comme en espresso.</p>
</div>
</section>
</main><main>
<section class="hero hero-court">
<h1>Nous trouver</h1>
<p class="accroche">Ouvert du mardi au dimanche.</p>
</section>
<section>
<h2>Horaires</h2>
<p><strong>Du mardi au samedi</strong>, de 8 h à 19 h.<br>Le dimanche, de 9 h à 13 h.</p>
</section>
<section>
<h2>Adresse</h2>
<p>12 rue Sainte-Catherine, 69001 Lyon.<br>Métro Hôtel de Ville.</p>
</section>
</main>// Collapsible menu on small screens.
var burger = document.querySelector('.burger');
var menu = document.querySelector('.menu');
burger.addEventListener('click', function () {
var ouvert = menu.classList.toggle('ouvert');
burger.setAttribute('aria-expanded', String(ouvert));
});
// Footer year.
document.getElementById('annee').textContent = new Date().getFullYear();What becomes what
This is the only real thinking in the workshop, and it does not automate: no tool can decide what the client is allowed to change.
| On the original site | Becomes | Why |
|---|---|---|
| Each page's heading and tagline | text fields |
Their size and colour are editorial decisions. |
| The “Notre torréfaction” paragraph | A richtext field |
Prose: bold and links yes, size no. |
| The photos | media fields |
They will change at the first change of season. |
| The list of reviews, the grid of coffees | Collections | The client will add and remove some. |
| The menu, the address, the phone number | site.json |
Structure: one broken link breaks the whole site. |
| The “Horaires” and “Adresse” labels | Nothing, they stay in the layout | They are labels, not content. |
| The CSS grids | Nothing, they do not move | The design of the site does not belong to the client. |
A zone made editable later costs five minutes. A zone made editable by mistake, which the client breaks in month three, costs a call, a diagnosis and a fix.
Step 1 — Set the project up alongside
We do not convert in place. We create a fresh project and move the old site into it piece by piece — the old one stays online the whole time.
npm create inline@latest bergamote -- --nom "Café Bergamote" \
--courriel bonjour@cafe-bergamote.fr --langue fr
cd bergamote
npm install
npm run build
npm run servehttp://127.0.0.1:8788/fr/ shows the sample site shipped with the project. Note in passing the site key printed at creation: it will never be shown again.
Then clear the decks — these three sample files will not be used:
rm src/components/Testimonial.astro
rm src/content/pages/fr/home.json
mkdir -p src/vues public/jsStep 2 — The stylesheet
It gets copied without a single edit. This is the point that reassures people most when deciding on a port: the site does not change how it looks.
cp ../cafe-bergamote/assets/css/style.css src/styles/site.css
If you are following along with the sample site, here are its exact contents — the original
style.css, as is:
/**
* The original site's stylesheet, copied without modification.
*/
:root {
--encre: #241c16;
--papier: #fffdf9;
--brique: #a4622a;
}
body {
margin: 0;
font-family: 'Iowan Old Style', Georgia, serif;
color: var(--encre);
background: var(--papier);
line-height: 1.6;
}
.entete {
display: flex;
align-items: center;
gap: 2rem;
padding: 1rem 5vw;
border-bottom: 1px solid #e3d8c9;
}
.marque { font-size: 1.4rem; font-weight: 600; text-decoration: none; color: inherit; }
.menu { display: flex; gap: 1.25rem; margin-left: auto; }
.menu a { color: var(--encre); text-decoration: none; font-size: 0.9rem; }
.burger {
display: none;
margin-left: auto;
border: 1px solid #e3d8c9;
background: none;
font: inherit;
padding: 0.3rem 0.7rem;
border-radius: 4px;
}
main { max-width: 46rem; margin: 0 auto; padding: 0 5vw 4rem; }
.hero { padding: 4rem 0 2rem; text-align: center; }
.hero h1 { font-size: 3rem; font-weight: 700; margin: 0 0 0.5rem; }
.hero-court { padding: 2.5rem 0 1.5rem; }
.accroche { font-size: 1.3rem; color: #8a7a6c; margin: 0; }
section { margin: 3rem 0; }
img, iframe { max-width: 100%; height: auto; }
.liste-avis {
list-style: none;
display: grid;
gap: 2rem;
padding: 0;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
}
.liste-avis li { border-left: 3px solid var(--brique); padding-left: 1rem; }
.liste-avis blockquote { margin: 0; font-style: italic; }
.liste-avis cite { display: block; margin-top: 0.4rem; font-size: 0.85rem; color: #8a7a6c; }
.cafes {
display: grid;
gap: 2.5rem;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
.cafe img { width: 100%; height: auto; border-radius: 4px; }
.cafe h2 { margin: 0.75rem 0 0; }
.origine { color: var(--brique); font-weight: 500; margin: 0.2rem 0 0.6rem; }
.pied {
border-top: 1px solid #e3d8c9;
padding: 2rem 5vw 3rem;
color: #8a7a6c;
font-size: 0.875rem;
}
.pied p { margin: 0.2rem 0; }
@media (max-width: 40rem) {
.burger { display: block; }
.menu { display: none; }
.menu.ouvert { display: flex; flex-direction: column; }
}
One thing remains, and only one: tokens.css — the package's sheet that turns
style steps into CSS properties — expects variables under precise names, which the old sheet
does not declare. So we write a bridge.
/**
* The bridge between the old stylesheet and the inline schema.
*
* tokens.css expects variables under precise names. The old site had its own:
* we wire one onto the other, changing nothing in site.css.
*/
:root {
/* The existing site's colours, under the names the schema expects */
--color-primary: var(--encre, #241c16);
--color-secondary: #4a3d33;
--color-muted: #8a7a6c;
--color-accent: var(--brique, #a4622a);
--color-inverse: var(--papier, #fffdf9);
--color-surface: var(--papier, #fffdf9);
--color-surface-alt: #f6f0e7;
--color-border: #e3d8c9;
/* The scale read out of site.css, step by step */
--size-xs: 0.75rem;
--size-sm: 0.875rem; /* .liste-avis cite */
--size-base: 1rem;
--size-lg: 1.3rem; /* .accroche */
--size-xl: 1.6rem;
--size-2xl: 2.1rem;
--size-3xl: 3rem; /* .hero h1 */
--weight-thin: 100;
--weight-light: 300;
--weight-regular: 400;
--weight-medium: 500; /* .origine */
--weight-semibold: 600; /* .marque */
--weight-bold: 700; /* .hero h1 */
--font-body: 'Iowan Old Style', Georgia, serif;
--line-height: 1.6;
}Open the old sheet and note the sizes and weights actually in use, as commented above. A forgotten variable raises no error: the browser drops the property and inherits something else. The page renders, and nobody notices that half the site changed typeface.
tokens.css declares single-level classes — .cms-color-primary. If
your sheet contains .hero h1 { color: … }, at two levels, that one wins and the
colour step the client picks will have no effect. Two ways out: remove the property from the
old rule, or do not make that field's colour editable.
Step 3 — The images
cp ../cafe-bergamote/assets/img/*.jpg src/media/
Then rename them to lowercase, without accents or spaces. In our example the three files are
called devanture, salle and torrefaction — keep
whatever extension you have, and write the same one in the content files of step 6.
From src/media/, Astro produces AVIF, WebP and several widths at build time,
with a fingerprint in the served name. The old site served a single JPEG. That is a gain
that arrives without asking.
Step 4 — The JavaScript
The original script does two things. One is kept as is, the other disappears — and its disappearance is a gain.
| What the script did | Becomes | Why |
|---|---|---|
| Collapse the menu on small screens | Unchanged, in public/js/menu.js |
It is progressive enhancement: the page works without it. |
| Write the year into the footer | Computed at build time, in step 5 | It lands in the served HTML, hence in search engine indexes. |
// Taken as is from the old site, minus the year line.
var burger = document.querySelector('.burger');
var menu = document.querySelector('.menu');
if (burger && menu) {
burger.addEventListener('click', function () {
var ouvert = menu.classList.toggle('ouvert');
burger.setAttribute('aria-expanded', String(ouvert));
});
}
The old getElementById('annee').textContent = … was harmless because the footer
is not editable. The same gesture on a data-cms zone would wipe, on load,
whatever the client had just typed. The rule: no script touches the content of an
editable zone. Animations, counters and carousels stay allowed as long as they move
the markup without rewriting it.
Step 5 — The shell and the layout
This is the step that turns three files that resemble each other into one file and three pages. Everything identical across pages moves up here.
---
/**
* The document shell: what was identical in the three original HTML pages —
* the head, the header, the footer, the script.
*/
import 'inline-core/styles/tokens.css';
import '../styles/theme.css';
import '../styles/site.css';
import { site } from '../content/site';
import { LOCALE_LABELS } from '../lib/locales';
interface Props {
title: string;
description: string;
/** Source content file — the overlay's anchor point, inert otherwise. */
contentFile: string;
locale: string;
pageName: string;
alternates?: Array<{ locale: string; href: string }>;
untranslated?: number;
}
const {
title,
description,
contentFile,
locale,
pageName,
alternates = [],
untranslated = 0,
} = Astro.props;
/**
* The year used to be computed in JavaScript on the old site. At build time it
* lands in the served HTML: a visitor without JavaScript sees it too.
*/
const annee = new Date().getFullYear();
---
<!doctype html>
<html lang={locale}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={new URL(Astro.url.pathname, Astro.site).href} />
{
alternates.map((entry) => (
<link
rel="alternate"
hreflang={entry.locale}
href={new URL(entry.href, Astro.site).href}
/>
))
}
</head>
<body
data-cms-file={contentFile}
data-cms-locale={locale}
data-cms-page={pageName}
data-cms-untranslated={untranslated > 0 ? String(untranslated) : undefined}
>
{/* The same markup as before, classes included: nothing moved. */}
<header class="entete">
<a class="marque" href={`/${locale}/`}>{site.name}</a>
<button class="burger" aria-expanded="false" aria-controls="menu">Menu</button>
<nav class="menu" id="menu">
{site.navigation.map((item) => <a href={item.href}>{item.label}</a>)}
{
alternates
.filter((entry) => entry.locale !== locale)
.map((entry) => (
<a href={entry.href} hreflang={entry.locale} lang={entry.locale}>
{LOCALE_LABELS[entry.locale as keyof typeof LOCALE_LABELS] ?? entry.locale}
</a>
))
}
</nav>
</header>
<slot />
<footer class="pied">
<p>{site.contact.address} — {site.contact.phone}</p>
<p>© {annee} {site.name}</p>
</footer>
<script src="/js/menu.js" is:inline></script>
</body>
</html>
entete, marque, burger, menu,
pied: not one character of difference. That is the condition for
site.css to keep applying untouched. is:inline on the
<script> tag tells Astro to leave it alone.
The <main> and the “hero” section repeated too. They go into an
intermediate layout:
---
/**
* The layout of a page: the <main> and the « hero » section, which the three
* original pages repeated identically.
*/
import Base from './Base.astro';
interface Props {
title: string;
description: string;
contentFile: string;
locale: string;
pageName: string;
alternates?: Array<{ locale: string; href: string }>;
untranslated?: number;
/** « hero-court » on inner pages, as on the old site. */
court?: boolean;
}
const { court = false, ...coquille } = Astro.props;
---
<Base {...coquille}>
<main>
<section class={court ? 'hero hero-court' : 'hero'}>
<slot name="entete" />
</section>
<slot />
</main>
</Base>
Page.astro knows nothing about blocks.page.titre: it exposes a
named slot, and the route is what puts the component in it. A layout reaching for a path in
the JSON would only serve pages that have that block.
Step 6 — The content
Four files. The first is structure, the other three belong to the client.
{
"name": "Café Bergamote",
"locale": "fr",
"navigation": [
{ "label": "Accueil", "href": "/fr/" },
{ "label": "La carte", "href": "/fr/carte/" },
{ "label": "Nous trouver", "href": "/fr/contact/" }
],
"contact": {
"email": "bonjour@cafe-bergamote.fr",
"phone": "04 78 00 00 00",
"address": "12 rue Sainte-Catherine, 69001 Lyon"
},
"footer": { "legal": "Café Bergamote" }
}{
"meta": {
"title": "Café Bergamote — torréfaction artisanale à Lyon",
"description": "Café de spécialité torréfié sur place, rue Sainte-Catherine à Lyon."
},
"blocks": {
"page": {
"titre": {
"type": "text",
"value": "Torréfié sur place, tous les mardis",
"style": { "size": "3xl", "weight": "bold", "italic": false, "align": "center", "color": "primary" }
},
"chapo": {
"type": "text",
"value": "Café de spécialité, rue Sainte-Catherine depuis 2019.",
"style": { "size": "lg", "weight": "regular", "italic": false, "align": "center", "color": "muted" }
}
},
"hero": {
"photo": {
"type": "media",
"kind": "image",
"src": "devanture.webp",
"alt": "La devanture du café, un matin d'hiver",
"width": 1600,
"height": 900
}
},
"propos": {
"titre": {
"type": "text",
"value": "Notre torréfaction",
"style": { "size": "2xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
},
"corps": {
"type": "richtext",
"value": "Nous achetons en direct producteur et torréfions <strong>en petites séries</strong>, le mardi matin. Le café part en boutique le jour même. <a href=\"/fr/carte/\">Voir la carte</a>."
}
},
"avis": {
"titre": {
"type": "text",
"value": "Ce qu'on en dit",
"style": { "size": "2xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
}
}
},
"collections": {
"avis": [
{
"id": "a-001",
"citation": {
"type": "text",
"value": "Le meilleur espresso du quartier, sans discussion.",
"style": { "size": "base", "weight": "regular", "italic": true, "align": "left", "color": "primary" }
},
"auteur": {
"type": "text",
"value": "Claire D.",
"style": { "size": "sm", "weight": "regular", "italic": false, "align": "left", "color": "muted" }
}
},
{
"id": "a-002",
"citation": {
"type": "text",
"value": "On y vient pour le café, on y reste pour l'accueil.",
"style": { "size": "base", "weight": "regular", "italic": true, "align": "left", "color": "primary" }
},
"auteur": {
"type": "text",
"value": "Malik T.",
"style": { "size": "sm", "weight": "regular", "italic": false, "align": "left", "color": "muted" }
}
}
]
}
}
The old site wrote sibling files — carte.html. Astro serves one folder per
page: the same link becomes /fr/carte/, absolute, otherwise it would resolve
under the current page. This is the most frequent oversight in a port, and it only shows up
on click.
{
"meta": {
"title": "La carte — Café Bergamote",
"description": "Torréfaction du mardi, mise à jour chaque semaine."
},
"blocks": {
"page": {
"titre": {
"type": "text",
"value": "La carte",
"style": { "size": "3xl", "weight": "bold", "italic": false, "align": "center", "color": "primary" }
},
"chapo": {
"type": "text",
"value": "Torréfaction du mardi, mise à jour chaque semaine.",
"style": { "size": "lg", "weight": "regular", "italic": false, "align": "center", "color": "muted" }
}
}
},
"collections": {
"cafes": [
{
"id": "c-001",
"nom": {
"type": "text",
"value": "Éthiopie Sidamo",
"style": { "size": "xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
},
"origine": {
"type": "text",
"value": "Lavé — notes de bergamote et d'abricot",
"style": { "size": "base", "weight": "medium", "italic": false, "align": "left", "color": "accent" }
},
"texte": {
"type": "richtext",
"value": "Notre café signature, celui qui a donné son nom à la maison."
},
"photo": {
"type": "media",
"kind": "image",
"src": "torrefaction.webp",
"alt": "Grains en cours de refroidissement après torréfaction",
"width": 1600,
"height": 900
}
},
{
"id": "c-002",
"nom": {
"type": "text",
"value": "Colombie Huila",
"style": { "size": "xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
},
"origine": {
"type": "text",
"value": "Lavé — chocolat noir et noisette",
"style": { "size": "base", "weight": "medium", "italic": false, "align": "left", "color": "accent" }
},
"texte": {
"type": "richtext",
"value": "Le plus rond de la carte, parfait en filtre comme en espresso."
},
"photo": {
"type": "media",
"kind": "image",
"src": "salle.webp",
"alt": "La salle du café en fin de journée",
"width": 1600,
"height": 900
}
}
]
}
}{
"meta": {
"title": "Nous trouver — Café Bergamote",
"description": "12 rue Sainte-Catherine, 69001 Lyon. Ouvert du mardi au dimanche."
},
"blocks": {
"page": {
"titre": {
"type": "text",
"value": "Nous trouver",
"style": { "size": "3xl", "weight": "bold", "italic": false, "align": "center", "color": "primary" }
},
"chapo": {
"type": "text",
"value": "Ouvert du mardi au dimanche.",
"style": { "size": "lg", "weight": "regular", "italic": false, "align": "center", "color": "muted" }
}
},
"infos": {
"horaires": {
"type": "richtext",
"value": "<strong>Du mardi au samedi</strong>, de 8 h à 19 h.<br>Le dimanche, de 9 h à 13 h."
},
"adresse": {
"type": "richtext",
"value": "12 rue Sainte-Catherine, 69001 Lyon.<br>Métro Hôtel de Ville."
}
}
}
}Step 7 — The components and the views
Five files: two components for list items, three views for page bodies.
---
/**
* One review. The same markup as in the old <li>: blockquote then cite.
*/
import type { CollectionItem } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';
interface Props {
item: CollectionItem;
path: string;
untranslated?: Set<string>;
}
const { item, path, untranslated } = Astro.props;
---
<Editable
path={`${path}.citation`}
field={item.citation as any}
as="blockquote"
untranslated={untranslated}
/>
<Editable path={`${path}.auteur`} field={item.auteur as any} as="cite" untranslated={untranslated} />---
/**
* One coffee on the menu: a photo and three texts. The same markup as the old
* <div class="cafe">.
*/
import type { CollectionItem } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';
import Media from 'inline-core/components/Media.astro';
interface Props {
item: CollectionItem;
path: string;
untranslated?: Set<string>;
}
const { item, path, untranslated } = Astro.props;
/**
* « Media » expects the whole page, an item is only a piece of it: so we hand
* it that piece in the shape it knows how to read.
*/
const [, liste, id] = path.split('.');
const morceau = { collections: { [liste]: { [id]: item } } } as any;
---
<div class="cafe">
<Media
data={morceau}
path={`${path}.photo`}
widths={[400, 800, 1200]}
sizes="(max-width: 40rem) 100vw, 20rem"
/>
<Editable path={`${path}.nom`} field={item.nom as any} as="h2" untranslated={untranslated} />
<Editable
path={`${path}.origine`}
field={item.origine as any}
as="p"
class="origine"
untranslated={untranslated}
/>
<Editable path={`${path}.texte`} field={item.texte as any} as="p" untranslated={untranslated} />
</div>---
/**
* The body of the old index.html, minus the header and the footer.
*/
import type { Page } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';
import Media from 'inline-core/components/Media.astro';
import Collection from 'inline-core/components/Collection.astro';
import Avis from '../components/Avis.astro';
interface Props {
data: Page;
missing: Set<string>;
}
const { data, missing } = Astro.props;
---
<section>
<Media
data={data}
path="blocks.hero.photo"
widths={[480, 800, 1200, 1600]}
sizes="(max-width: 48rem) 100vw, 46rem"
/>
</section>
<section class="propos">
<Editable data={data} path="blocks.propos.titre" as="h2" untranslated={missing} />
<Editable data={data} path="blocks.propos.corps" as="p" untranslated={missing} />
</section>
<section>
<Editable data={data} path="blocks.avis.titre" as="h2" untranslated={missing} />
<Collection
data={data}
name="avis"
item={Avis}
class="liste-avis"
untranslated={missing}
blank={{
citation: {
type: 'text',
value: 'Leur retour, en une phrase.',
style: { size: 'base', weight: 'regular', italic: true, align: 'left', color: 'primary' },
},
auteur: {
type: 'text',
value: 'Prénom N.',
style: { size: 'sm', weight: 'regular', italic: false, align: 'left', color: 'muted' },
},
}}
/>
</section>---
/**
* The body of the old carte.html: the grid of coffees, now a list the client
* fills in themselves.
*/
import type { Page } from 'inline-core/schema';
import Collection from 'inline-core/components/Collection.astro';
import Cafe from '../components/Cafe.astro';
interface Props {
data: Page;
missing: Set<string>;
}
const { data, missing } = Astro.props;
---
<section>
<Collection
data={data}
name="cafes"
item={Cafe}
class="cafes"
untranslated={missing}
blank={{
nom: {
type: 'text',
value: 'Nouveau café',
style: { size: 'xl', weight: 'semibold', italic: false, align: 'left', color: 'primary' },
},
origine: {
type: 'text',
value: 'Origine — notes de dégustation',
style: { size: 'base', weight: 'medium', italic: false, align: 'left', color: 'accent' },
},
texte: { type: 'richtext', value: 'Décrivez-le en une phrase.' },
photo: {
type: 'media',
kind: 'image',
src: 'torrefaction.webp',
alt: 'Photo à remplacer',
width: 1600,
height: 900,
},
}}
/>
</section>---
/**
* The body of the old contact.html. The form is not ported.
*/
import type { Page } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';
interface Props {
data: Page;
missing: Set<string>;
}
const { data, missing } = Astro.props;
---
<section>
<h2>Horaires</h2>
<Editable data={data} path="blocks.infos.horaires" as="p" untranslated={missing} />
</section>
<section>
<h2>Adresse</h2>
<Editable data={data} path="blocks.infos.adresse" as="p" untranslated={missing} />
</section>Step 8 — The route
One file replaces the three HTML files. It is the last one of the port.
---
/**
* The single route: one URL per page and per language.
*/
import { getCollection } from 'astro:content';
import type { Page as Contenu } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';
import Page from '../../layouts/Page.astro';
import Accueil from '../../vues/Accueil.astro';
import Carte from '../../vues/Carte.astro';
import Contact from '../../vues/Contact.astro';
import { DEFAULT_LOCALE, LOCALES, localePath, mergeWithDefault } from '../../lib/locales';
export async function getStaticPaths() {
const entries = await getCollection('pages');
const byId = new Map(entries.map((entry) => [entry.id, entry]));
const pages = entries
.filter((entry) => entry.id.startsWith(`${DEFAULT_LOCALE}/`))
.map((entry) => entry.id.slice(DEFAULT_LOCALE.length + 1));
return LOCALES.flatMap((locale) =>
pages.map((page) => {
const reference = byId.get(`${DEFAULT_LOCALE}/${page}`)!;
const translation = byId.get(`${locale}/${page}`);
const { data, untranslated } = mergeWithDefault<Contenu>(
reference.data as Contenu,
translation?.data as Contenu | undefined,
);
return {
params: { lang: locale, slug: page === 'home' ? undefined : page },
props: { locale, page, data, untranslated },
};
}),
);
}
const { locale, page, data, untranslated } = Astro.props;
const missing = new Set(untranslated);
const alternates = LOCALES.map((code) => ({ locale: code, href: localePath(code, page) }));
/** One view per page, as there used to be one HTML file per page. */
const VUES: Record<string, any> = { home: Accueil, carte: Carte, contact: Contact };
const Vue = VUES[page];
---
<Page
title={data.meta.title}
description={data.meta.description}
contentFile={`src/content/pages/${locale}/${page}.json`}
locale={locale}
pageName={page}
court={page !== 'home'}
alternates={alternates}
untranslated={untranslated.length}
>
<Fragment slot="entete">
<Editable data={data} path="blocks.page.titre" as="h1" untranslated={missing} />
<Editable data={data} path="blocks.page.chapo" as="p" class="accroche" untranslated={missing} />
</Fragment>
<Vue data={data} missing={missing} />
</Page>npm run build
The build announces 5 pages — your three pages, plus /admin
and /aide. Open /fr/, /fr/carte/ and
/fr/contact/: it is the site from before, identical.
Step 9 — The two adjustments
Two things did move after all. They are small, but they have to be done.
9.1 — One CSS selector
The Collection component renders a <div> with one
<article> per item, where the old HTML had a <ul> with
<li> elements. The class itself is kept: only one rule needs redoing.
/* before */
.liste-avis li { border-left: 3px solid var(--brique); padding-left: 1rem; }
/* after */
.liste-avis article { border-left: 3px solid var(--brique); padding-left: 1rem; }9.2 — The HTML check
The scripts/check-html.mjs script ships wired to a single page. Left as it is, it
reports “all good” having checked only the home page. Two edits, both within its first
twenty-five lines. First the opening import line, which is missing
readdirSync:
// before
import { readFileSync, existsSync } from 'node:fs';
// after
import { readFileSync, readdirSync, existsSync } from 'node:fs';
Then the list of pages, written by hand and cut down to a single entry. Look for these two
lines — it is the only place in the file where const PAGES appears:
/** Une entrée par page construite, toutes langues confondues. */
const PAGES = [{ content: 'src/content/pages/fr/home.json', html: 'dist/fr/index.html' }];And replace them — those two lines, not one more — with these:
/** Must stay in agreement with src/lib/locales.ts. */
const LOCALES = ['fr'];
/**
* One entry per built page, all languages together.
*
* The list is derived from the content, not written by hand: a page forgotten
* from a manual list would be a page nobody checks, and the check would still
* report « all good ».
*/
const PAGES = LOCALES.flatMap((locale) =>
readdirSync(join(root, 'src/content/pages', locale))
.filter((name) => name.endsWith('.json'))
.map((name) => {
const page = name.slice(0, -'.json'.length);
return {
content: `src/content/pages/${locale}/${name}`,
html: page === 'home' ? `dist/${locale}/index.html` : `dist/${locale}/${page}/index.html`,
};
}),
);Step 10 — Checking equivalence
The goal of a port is not “it works”, it is “it is the same site”. Four checks say so.
npm run build && npm run check“3 page(s) vérifiée(s)”, not 1. Then, with the server restarted:
# 1. the content is in the source, with no JavaScript — must return 1
curl -s http://127.0.0.1:8788/fr/ | grep -c "Torréfié sur place"
# 2. the editing routes answer — must return 405, not 404
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8788/api/auth
# 3. the original classes are there — must return 1 for each
curl -s http://127.0.0.1:8788/fr/ | grep -c 'class="entete"'
curl -s http://127.0.0.1:8788/fr/ | grep -c 'class="liste-avis"'And once online, the one everybody forgets:
# old addresses must return a 301, not a 404
curl -sI https://cafe-bergamote.fr/carte.html | head -1
/carte.html becomes /fr/carte/. A site that already ranks cannot
afford to lose its addresses: set a permanent redirect from every old one to the new one, at
the host or in astro.config.mjs. On switchover day, not the week after.
Open the old and the new side by side, at the same width, then on mobile. Remaining
differences almost always come from a variable missing in theme.css or a
two-level selector tokens.css cannot beat — the two traps from step 2.
What does not port
| Case | Why | What to do |
|---|---|---|
| Forms | Out of scope: they need a server that receives. | They stay what they were — a third-party service or a mailto:. |
| Browser-generated content | If it is not in the HTML, there is nothing to port. | Freeze it into the page first. It is an SEO gain along the way. |
| A carousel that loads its images by script | The markup must contain all its elements outright. | Write them into the page; the script only scrolls them. |
| PHP or SSI includes | They no longer have a reason to exist. | That is exactly what the layout from step 5 replaces. |
And then
- The original stylesheet is imported as is, and the classes have not moved.
- Every variable
tokens.cssexpects is declared. - The header and footer are written once.
- No script rewrites the content of an editable zone.
- Internal links are absolute and language-prefixed.
- Images are in
src/media/, with a description and dimensions. npm run buildandnpm run checkpass, on all three pages.- Old addresses will return a 301 on switchover day.
For client-side editing — key, overlay, publishing — the procedure is the same as in the previous workshop: step 12. To add a second language to this ported site, it is step 10 of the same workshop, word for word.
The home page alone first, then approval by the client — that is when you find out they also wanted to edit the footer's opening hours. Then the pages whose content changes. The frozen pages last, or never: a legal notice and a 404 page have nothing to gain from becoming editable.