Skip to content

feat: rebuild the site with astro and add a blog - #454

Merged
choffmann merged 84 commits into
mainfrom
feat/astro-migration
Aug 25, 2026
Merged

feat: rebuild the site with astro and add a blog#454
choffmann merged 84 commits into
mainfrom
feat/astro-migration

Conversation

@choffmann

Copy link
Copy Markdown
Member

No description provided.

Astro 7.2.5 takes over dev server, build and routing. Static output with
trailingSlash 'never' and build.format 'file' keeps the existing URLs
(/de/project, no trailing slash). i18n routing is configured with de as
defaultLocale and prefixDefaultLocale, so both languages keep their own
indexable paths.

The VITE_ prefix is kept via vite.envPrefix so Dockerfile and CI stay
untouched. The lottie-react resolve alias moves over unchanged: vite would
otherwise hand <Lottie /> a UMD exports object instead of a component.

astro/tsconfigs/strict enables verbatimModuleSyntax, which the previous
config did not, so four src/data files needed a type-only import.

src/i18n/useLanguage.ts is removed rather than excluded: it imports
@tanstack/react-router, which this commit uninstalls, so it is dead code
from here on. The SPA under src/tsx and src/routes is temporarily excluded
from check and lint; plan 3 removes those directories and the exclusion.
…lback

resolve() is catalog-agnostic so it can be unit-tested against fixtures
instead of the real 70 KB catalogs. It walks a dotted path, fills {{vars}},
picks the plural category via Intl.PluralRules and falls back to German.

An unresolvable key returns '??' + key rather than throwing. The build-output
check in task 7 greps the generated HTML for that marker, which turns a
missing translation into a failing build instead of a silent gap on the page.

Vitest replaces the previous 'echo' test script.
…ce language

TranslationKey<N> walks the German catalog and produces the union of its
dotted paths, collapsing the _one/_other plural suffixes onto their base key.
An invalid key is now a type error, which replaces the hand-written
i18next.d.ts augmentation with something derived from the actual content.

The test asserts against overview.currentReleaseLabel rather than
overview.eyebrow: the latter reads "Releases" in both languages, so it
could not detect a language mix-up.
pickStrings resolves an explicit list of keys into a flat record. Islands get
that record, never a whole namespace: home.json alone is 21 KB and would end
up as serialised JSON in the HTML.

Plural keys cannot be picked, because the count is only known inside the
island. A test pins that behaviour so it is not mistaken for a bug; an island
needing a plural receives both variants as separate props.

getTranslations replaces the name useTranslations from Astro's recipe. The
'use' prefix made ESLint enforce React hook rules on a build-time function,
which flagged the call from pickStrings as a hook outside a component. useT
keeps its prefix because it really is a hook, and it lives in its own file so
TranslationProvider.tsx exports nothing but the component.
Replaces the hand-rolled 180-line frontmatter parser with a Zod schema on
Astro's content layer. Frontmatter is now validated: an unknown changelog
type fails the build with the entry and field named, where it previously
rendered blank. Verified by injecting an invalid type, which exits 1 with
InvalidContentEntryDataError.

generateId keeps the file path verbatim. Astro's default id slugifies, which
turned de/v0.5.0 into de/v050 and would have changed every public release URL.

The language rules live in releaseSelectors.ts as pure functions over an entry
array, with releases.ts as the thin layer that calls getCollection. That split
exists because getCollection needs Astro's content layer, which a bare vitest
process does not have; the selectors are testable, the wiring is covered by
the build.

Not src/content/releases.ts: that path collides with the content directory of
the same name, and ./releases resolved to its old index.ts instead.
…age redirect

BaseLayout emits title, description, canonical, hreflang including x-default
and the OG tags into the HTML at build time. documentMeta.ts built the same
tags imperatively after mount, where a crawler without JavaScript saw none of
them. Omitting the path prop suppresses canonical and hreflang, which is what
the 404 page needs: a self-referencing canonical would claim the URL is a real
page.

The language redirect on / has to run in the browser, because static output
cannot read the request. The order matches the previous detectLanguage.ts, and
it reads the same localStorage key the SPA wrote, so a returning visitor keeps
their choice. A meta refresh to the default language covers JavaScript being
off.

One 404 page, in German. Language variants would require nginx to derive a
language from a path that by definition belongs to no page.
nginx no longer falls back to index.html. With build.format 'file' the real
page is $uri.html, and a wrong path now returns 404 instead of the start page
with a 200. Verified in the image: /gibtesnicht is 404, the legacy redirects
/project and /releases/v0.5.1 still answer 301.

check-build-output.mjs asserts the expected files exist and greps the HTML for
the unresolved-key marker. It strips script bodies first: the JS nullish
operator is spelled like the marker, and legal:translationNotice shows keys are
not always dotted, so the pattern alone cannot tell them apart. Counter-tested
against a dotted key, an undotted key and a script full of '??'.
…hape

The schema now carries the optional repository field. The detail page reads it
with a fallback, and zod strips unknown keys, so without it a future value in a
release file would be silently discarded.

formatReleaseDate takes a Date rather than a string: z.coerce.date() already
parsed it. isoDate supplies the machine-readable form for the datetime
attribute, which the localised output cannot serve.
…splitter

The section rules move over unchanged, order included: first match wins, so
'Wichtige Hinweise zu neuen Funktionen' has to stay 'important' rather than
fall through to 'features'. A test pins that, and the rule order is diffed
against the original.

headingFromId is the new part. Astro derives heading ids from the text, so the
keywords can be matched against the id instead of needing a markdown plugin,
but hyphens have to become spaces first or multi-word keywords never match.

splitSlot replaces react-i18next's <Trans>. The catalog keeps a neutral marker
such as <issue>…</issue> rather than raw HTML, so a translator cannot break the
markup and nothing needs set:html.
The page ships no JavaScript. Lucide icons are React components used without a
client directive, so astro renders them to plain svg at build time: nine of them
on the German page, zero astro-island elements.

Title, description, canonical, all three hreflang links and the OG tags now sit
in the HTML rather than being written after mount. Year grouping reads
date.getFullYear() instead of slicing a string, because the schema hands over a
Date.
Eighteen pages, nine per language, none of them shipping JavaScript. The
section icons come from an mdx component override rather than a markdown
plugin: astro hands the generated heading id to the override, and the keyword
rules match against it once hyphens are turned back into spaces. Verified on
v0.1.0, where four headings resolve to the brand tone and two to the muted one.

splitSlot replaces the Trans component. The rendered page carries a real anchor
with the translated label, and the catalog keeps a neutral marker instead of
HTML.

The schema gains commit and the style, test and ci changelog types. Neither
appears in a release file yet, but the changelog component renders both, and
zod would have dropped them silently.

An unknown slug no longer redirects to the overview. getStaticPaths only emits
slugs that exist, so nginx answers 404, which is the right answer for an
address that never existed.
Exactly one astro-island per detail page, and none on v0.2.1, which has a
single heading and therefore falls below the three-section threshold.

The island receives its two labels through pickStrings rather than a namespace.
The serialised props carry toc.ariaLabel and toc.title and nothing else, which
is the rule that keeps island payloads from growing with the catalog.

Entries come from the headings astro returns from render(), so the anchors and
the ids on the rendered headings are generated by the same source and cannot
drift apart.
…ages

Fourteen files go: the 180-line frontmatter parser, both release pages, the six
react components, three helpers and the two router routes. The exclusion for
src/content/releases/index.ts is lifted from eslint and tsconfig with it.

src/tsx/components/sections/LatestRelease.tsx keeps two imports that no longer
resolve, to content/releases and helper/formatDate. It sits on the home page,
which has not been ported and has not been buildable since astro replaced vite,
so nothing regresses. Plan 4 points it at src/lib/releases.ts and
src/lib/formatDate.ts when it ports the home page.
Arrow, Button, Hero and BreadcrumbSchema carry no state and become plain
markup. Button drops the router: the caller passes a finished path, which
spares the building block any knowledge of languages. BreadcrumbSchema takes
finished names instead of catalog keys, because the page already holds its t.

Attribute spelling follows HTML now, so stroke-width and stroke-linecap rather
than the react casing.

No page imports these yet, so the build does not exercise them. astro check
covers them; tasks 2 to 5 put them to work.
Without a router the switcher collapses into two links. Astro.url.pathname is
known at build time, so the path behind the language prefix can be reused
directly.

The data-lang-switch attribute is the hook for the shell script in task 4. The
old switcher wrote the choice to localStorage on click, and the redirect on /
reads exactly that key, so without the attribute a returning visitor would
silently lose their language.

The template literal keys type-check on their own: language.${entry} maps onto
the union of both catalog keys, so no cast is needed.
… menu

Two islands: the lottie logo, which needs a runtime library and the visitor's
motion preference, and the navigation, which owns the open state, the outside
click, the swipe-to-close and the focus restore. The skip link, the logo link
and the wordmark stay static markup.

The nav items live inside the island rather than in an .astro component. The
same list serves both viewports, horizontal on desktop and as a drawer on
mobile, so an astro twin would have no caller. The language switcher goes the
other way: astro passes it into the island as slotted static HTML, so it ships
no javascript of its own.

ARROW_PATH is shared between Arrow.astro and the react twin so the two icons
cannot drift apart.

HeaderLogo reuses the existing useReducedMotion hook. Setting state directly in
an effect is what react-hooks/set-state-in-effect rejects; the hook resolves it
with a lazy initialiser.

Nothing mounts the header yet, so the build does not exercise it. Task 4 puts
it into BaseLayout and verifies it there.
Closes the gap this plan exists for: the spec lists both as part of BaseLayout,
plan 1 left them out and plan 2 built on top of that. All twenty release pages
and the 404 now carry header, footer, skip link, scroll progress and
back-to-top. Two islands per page, three where a detail page has a table of
contents.

Scroll progress, header background, back-to-top and the remembered language
choice share one script rather than four react roots. It re-runs on
astro:after-swap because the ClientRouter swaps the document and the element
references go stale.

HeaderLogo now imports lottie-react inside the effect. Its module body touches
document, which crashed the server render of the island. The static logo shows
until the animation is loaded, which also covers visitors without javascript
and those who asked for reduced motion. Neither group saw a logo before.
Both pages are prose driven by the legal catalog, so the markup was carried
over mechanically rather than retyped. The check that matters: the set of
translation keys is identical to the react version, 24 for the imprint and 69
for the privacy policy, none missing on either side. The build-output check
covers the rest, since an unknown key would surface as ??key in the HTML.

The breadcrumb for the home page now uses the empty path. The old version
passed '/', which produced .../de/ with a trailing slash, an address that does
not exist under trailingSlash 'never'.

repository moves from z.string().url() to z.url(); the former is deprecated in
the zod version astro ships.
Eleven files: header, footer, both navigation components, the language
switcher, scroll progress, back-to-top, both legal pages and their two router
routes.

Button, BreadcrumbSchema and Arrow stay, against the plan's first draft. Arrow
has nine remaining callers, BreadcrumbSchema four pages and Button the 404
page, all of which plan 4 ports. The same holds for sections/Hero and the two
hooks.

__root.tsx keeps imports that no longer resolve. It belongs to the router,
which has not been buildable since astro replaced vite, and plan 4 removes
src/routes entirely.
The sections used useReducedMotion for one purpose only: toggling animation
classes. A media query does the same without shipping javascript, and it covers
animations no component knows about.

Two components will need more than the blanket rule when they are ported: the
watering clouds and the floating hero shapes also move to a different resting
position rather than only stopping. The blanket rule freezes an animation on its
end state, so those get their own rules here.
Three islands per page: logo, navigation and the splide carousel. Key parity
against the react version is 30 to 30 with nothing missing.

The island receives finished text rather than a catalog. Astro resolves the
four partner entries, their roles, descriptions and logo alt text, so no
provider and no pickStrings travel with the slider. Only splide's own i18n
object goes along, and it needs all ten slider keys: the hook uses
slider.slideLabel too, which this plan's first draft had left out.

The section header stays static markup outside the island. Worth knowing: on
this page splide destroys itself from 1024px up, so the desktop view is a plain
list and the carousel is a mobile-only affair.

The floating leaves lost their reducedMotion prop. Their keyframe returns to
its starting transform, so the css rule freezing animations on the end state
lands exactly on the neutral position; no separate resting rule is needed.
None of them needs javascript. The reducedMotion prop disappears with the
components: the pulse and the !animate-none override were only ever there to
stop css animations, which the media query now handles for the whole page.

Process builds its four detail keys in a loop. The numbers are a const tuple so
the template literal resolves to real catalog keys rather than widening to
string, which is what a cast would have papered over. Verified on the rendered
page: twelve detail items for three steps, six governance pillars, and german
and english headings that actually differ.
…rd island

One island in the lot, and only the lottie overlay: the section around it,
browser chrome included, is static markup.

Two things the blanket reduced-motion rule does not cover, both handled here.
The architecture flow particles are SVG SMIL animations, which a css media
query cannot stop, so they are hidden outright; that matches the react version,
which rendered them only when motion was allowed. And the watering scene needs
resting positions rather than a stopped animation: drive ends at left 110% and
cloudMove at translateX(400%), so freezing them on the end state would park the
truck and both clouds outside the frame. Their positions moved from inline
styles into classes so the media query can override them without !important.

The flowPath id is now unique per connection line. The react version reused one
id across all three, so every mpath resolved to the first path.

The Trans slot for the streamlet link becomes splitSlot, as elsewhere.
Section order comes from the react page, not from the plan: the plan had
Advantages and DashboardPreview the wrong way round. Verified on the rendered
page that all nine sections appear in the source order.

The react page also scrolled to window.location.hash on mount, which a static
page does not need. The browser resolves the fragment itself once the document
is there, and scroll-padding-top already offsets it below the fixed header.

Three islands per page: logo, navigation and the dashboard animation.
Fourteen files: both pages, the contact hero, eight project sections, the
advantage card and the two router routes.

Against the plan's list, four things stay. Stakeholder and sections/Contact are
still used by the home page, StakeholderCard by Stakeholder, and
useSliderTranslations by both Stakeholder and Introduction. Plan 5 takes them
with the home page.

sections/Hero goes, which the plan did not expect. It had four callers until
plan 3 removed the two legal pages; the remaining two are deleted here.
All four read the home namespace, which the plan had guessed wrong for
ProcessCompact; the check step caught it. HomepageDevider carries no
translations at all and takes no props.

ProcessCompact loses the router: the Link carried hash="process", which as a
plain anchor goes straight into the href.

Features keeps its lucide icons as component references rather than elements in
a data array, so astro can render them statically without a client directive.
renderSlots joins splitSlot in slotText. The faq answers nest paragraph, list
and emphasis markers around their links, which splitSlot cannot express: it
knows one marker with text either side. renderSlots maps the known markers onto
markup and escapes everything else, so a typo in the catalog shows up as
literal text and nothing from the catalog can inject markup of its own. Five
tests cover it, including the escaping.

The accordion becomes five <details> elements and ships no javascript. Verified
on the rendered page: five details, no extra island, no unreplaced marker, the
list and all links intact, and the FAQPage schema still emitted from
answerPlain.

LatestRelease reads the content collection. Its react version imported the
hand-rolled parser and helper/formatDate, both removed in plan 2, so its
imports had been dangling since then.
The pre-check for this component was wrong: it grepped for css classes and
inline styles and reported no animations, but the illustration drives six
rising data particles and the LoRaWAN waves with SVG SMIL. Same class of
element as the architecture diagram, and the same treatment.

data-particle is renamed to motion-only across motion.css and Architecture. The
rule is not about particles, it is about SMIL-driven decoration that a css
media query cannot stop, so it gets hidden instead. Seven such elements on this
section, six particles and the wave group.
Same shape as StakeholderSlider: the section header stays static markup, astro
resolves the four cards, and only splide's own i18n object travels with the
island. Verified on the rendered page: three islands, four cards in the props,
ten slider keys, and no namespace leaked into the payload.

Two cards from 640px, and from 1024px splide destroys itself, so the desktop
view is a plain grid.

No IntroductionCard.astro: the cards live inside the slides and therefore have
to be react, so an astro twin would have no caller. Same reasoning as NavItem in
plan 3.
Plan 1 replaced the vite entry without carrying over the contents of
index.html, and plans 2 to 5 built on top of that. Ten things were missing from
every page built so far.

The two that actually broke things: the body had no classes at all, so every
page rendered in browser-default typography instead of nunito-sans on white
with the project's text colour and overflow handling; and html lost
scroll-pt-40, so anchor jumps landed underneath the fixed header. The comment in
ReleaseToc even cites that class as the reason for its threshold.

The rest were absent rather than broken: all eight favicons, the author meta,
og:site_name, og:image, the Organization JSON-LD and the analytics script.
og:image is emitted absolute now, because social previews do not resolve
relative paths; twitter:card and twitter:image come along so the image is
actually used.

msapplication-TileImage is deliberately not carried over: it pointed at
/assets/favicon/ms-icon-150x150, a path that does not exist in public/.
Replaces 1.3 MB of TTF in public/ and a hand-written fonts.css with the
Fonts API: woff2 variants, preload links for the two above-the-fold faces
and metric-matched fallbacks. This also fixes NunitoSans-Bold, which was
declared as weight 600 and left real bold to be synthesised. The SVG text
labels move from font-family="Lato" to the CSS variable because the
generated family name is hashed.
6.4 MB of release PNGs were served raw from public/ through <img> without
dimensions. Moved to src/assets so Astro emits sized WebP with width and
height: the release notes reference them relatively, the two dashboard
sections use <Image> with a srcset, and the stakeholder island receives
getImage() URLs.
…obile

The logo island pulled lottie-web onto every page before the content had
settled; client:idle defers it. The navigation renders its desktop links
as static markup, so client:media limits hydration to viewports that show
the mobile menu.
APP_VERSION and VIDEO_BASE_URL are declared in env.schema and imported from
astro:env, which drops the VITE_ prefix shim and the hand-written
ImportMetaEnv. BUILD_VERSION was never read and goes away. The runtime
contract with nginx (window._env_.VITE_VIDEO_BASE_URL) is unchanged.
Files under /_astro/ carry a content hash and can be immutable; HTML must
revalidate so a stale page never points at assets a deployment removed.
README and the PR workflow's path filter still described the React/Vite
build. favicon.ico moves to public/ where browsers request it, .gitignore
and .prettierignore drop the vite and tanstack entries, eslint uses
defineConfig instead of the deprecated tseslint.config, and the
reduced-motion reset is declared once.
@choffmann
choffmann requested a review from a team as a code owner August 25, 2026 10:52
choffmann and others added 20 commits August 25, 2026 13:07
Fix mixed German quote typography: three quotes opened with the correct
low quote but closed with a straight ASCII quote, visible on the rendered
pages.

Reduce the recurring "claim, colon, elaboration" sentence shape from four
to seven occurrences per article down to two, keeping only the colons that
introduce a genuine list or procedure.

Correct two factual slips: the English community award post claimed the
pitch fits into three words while the English phrasing has four, and both
languages described test sites rather than sensors as lying in the ground.
Also disambiguate the two silent sites from the two sensors still missing.

Rework the English Germanisms (tailwind, behind X lies Y, appointment for
Termin, anamnesis, running with), align tense in the retrospective reports,
drop the repeated project pitch and the duplicated thanks formula, and
unify the time formats.
@choffmann
choffmann merged commit 2c11f5e into main Aug 25, 2026
6 checks passed
@choffmann
choffmann deleted the feat/astro-migration branch August 25, 2026 15:57
@green-ecolution green-ecolution Bot mentioned this pull request Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant