feat: rebuild the site with astro and add a blog - #454
Merged
Conversation
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.
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.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.