EN FR

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.

20 min read17 sectionsChapter 6 / 22

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.

cafe-bergamote/ — what exists text
index.html
carte.html
contact.html
assets/
  css/style.css
  js/main.js
  img/
    devanture.jpg
    salle.jpg
    torrefaction.jpg
Got your own site?

Skip 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:

index.html — the starting site html
<!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.

carte.html — only the part that differs html
<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>
contact.html — only the part that differs html
<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>
assets/js/main.js — the starting site js
// 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 siteBecomesWhy
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.
When in doubt, leave it as structure

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.

terminal bash
npm create inline@latest bergamote -- --nom "Café Bergamote" \
  --courriel bonjour@cafe-bergamote.fr --langue fr

cd bergamote
npm install
npm run build
npm run serve
Check

http://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:

terminal bash
rm src/components/Testimonial.astro
rm src/content/pages/fr/home.json
mkdir -p src/vues public/js

Step 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.

terminal bash
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:

src/styles/site.css — create this file css
/**
 * 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.

src/styles/theme.css — replace the whole file css
/**
 * 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;
}
Read the values off, do not invent them

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.

The specificity trap

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

terminal bash
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.

What the port gains along the way

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 didBecomesWhy
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.
public/js/menu.js — create this file js
// 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));
  });
}
A script that rewrites an editable zone breaks it

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.

src/layouts/Base.astro — replace the whole file astro
---
/**
 * 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>
The classes have not moved

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:

src/layouts/Page.astro — create this file astro
---
/**
 * 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>
A layout never reads the content

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.

src/content/site.json — replace the whole file json
{
  "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" }
}
src/content/pages/fr/home.json — create this file json
{
  "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" }
        }
      }
    ]
  }
}
Internal links change shape

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.

src/content/pages/fr/carte.json — create this file json
{
  "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
        }
      }
    ]
  }
}
src/content/pages/fr/contact.json — create this file json
{
  "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.

src/components/Avis.astro — create this file astro
---
/**
 * 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} />
src/components/Cafe.astro — create this file astro
---
/**
 * 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>
src/vues/Accueil.astro — create this file astro
---
/**
 * 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>
src/vues/Carte.astro — create this file astro
---
/**
 * 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>
src/vues/Contact.astro — create this file astro
---
/**
 * 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.

src/pages/[lang]/[...slug].astro — replace the whole file astro
---
/**
 * 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>
terminal bash
npm run build
Check

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.

src/styles/site.css — change one line css
/* 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:

scripts/check-html.mjs — change one line js
// 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:

scripts/check-html.mjs — the two lines to replace js
/** 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:

scripts/check-html.mjs — the replacement js
/** 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.

terminal bash
npm run build && npm run check
Check

3 page(s) vérifiée(s)”, not 1. Then, with the server restarted:

terminal bash
# 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:

terminal bash
# old addresses must return a 301, not a 404
curl -sI https://cafe-bergamote.fr/carte.html | head -1
Redirects, on switchover day

/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.

And compare by eye

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

CaseWhyWhat 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.css expects 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 build and npm run check pass, 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.

A porting order that works

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.