

1/1/1970
Wie ich SSG in 2 Tagen umgesetzt habe
Hallöchen! Vor einem Jahr hätte ich das noch für unmöglich gehalten. Aber ich habe gerade Static Site Generation (SSG) für Foony in 2 Tagen implementiert, und ich bin ziemlich begeistert davon. Es ist nicht das erste Mal, dass ich versuche, SSG für Foony zu lösen. Ich habe mir in der Vergangenheit NextJS, Vike, Astro, Gatsby und ein paar andere Lösungen angeschaut. Ich hatte sogar einen Fehlstart mit NextJS, bin aber an der Komplexität von Foonys SPA und Tausenden von Dateien gescheitert. Die Migration wäre ein Albtraum gewesen und hätte Monate gedauert. Außerdem hätte sie zusätzliche Komplexität für alle anderen mit sich gebracht, die an der Seite arbeiten, weil sie NextJS und seine Eigenheiten hätten lernen müssen.
Ich wollte etwas Leichtes und einfach Umzusetzendes. Etwas, das es uns erlaubt, Code weiterhin so zu schreiben, wie wir es gewohnt sind, ohne ständig an SSG denken zu müssen (mit Ausnahme von useMediaQuery, da gibt es keinen wirklichen Weg drumherum). Im Folgenden erkläre ich, warum ich mich für eine maßgeschneiderte Lösung entschieden habe, welche konkreten Herausforderungen mir begegnet sind (insbesondere mit den Suspense-Grenzen von React) und wie ich sie gelöst habe.
Warum keine Standardlösungen?
Als ich mich zum ersten Mal damit beschäftigte, SSG zu Foony hinzuzufügen, habe ich natürlich NextJS (Industriestandard), Vike und Astro in Betracht gezogen.
NextJS: Zu viel Migration
NextJS ist mächtig, aber es hätte eine massive Migration von Foonys bestehender React-SPA erfordert. Wir haben Tausende von Dateien, komplexe Routing-Logik und viel benutzerdefinierte Infrastruktur. Eine Migration zu NextJS hätte bedeutet:
- Unser gesamtes Routing-System neu zu schreiben
- Die Art, wie wir Spiele und Komponenten laden, umzustrukturieren
- Monatelange Arbeit, nur um wieder den gleichen Funktionsumfang zu erreichen
- Potenzielle Breaking Changes für Nutzer
- Die Art, wie wir Bilder handhaben, zu ändern
- Deutlich langsamere Build-Zeiten (potenziell 5 bis 30 Minuten. Ich habe keine konkreten Zahlen, die das untermauern, außer dieser 5 Jahre alten Diskussion auf GitHub)
- Das gesamte Team müsste etwas Neues lernen (NextJS), und die Entwicklergeschwindigkeit wäre dauerhaft langsamer
- Den Code jedes Mal migrieren, wenn NextJS sich für Breaking Changes entscheidet.
Ich habe sogar einen Fehlstart mit NextJS versucht, aber schnell gemerkt, dass die Migrationskosten zu hoch waren. Die Komplexität war es nicht wert.
Vike: Ähnliche Komplexität
Vike (früher vite-plugin-ssr) hatte ähnliche Probleme. Obwohl es flexibler als NextJS ist, hätte es immer noch erhebliche Umstrukturierungen unserer Codebasis erfordert. Die Lernkurve und der Migrationsaufwand rechtfertigten den Nutzen nicht.
Astro: Falsche Architektur
Astro ist großartig für inhaltslastige Seiten, aber Foony ist eine komplexe Multiplayer-Spieleplattform. Wir brauchen Echtzeit-Updates, WebSocket-Verbindungen und dynamische React-Komponenten. Astros Architektur passt einfach nicht zu dem, was wir bauen.
Die Lösung: Maßgeschneiderte SSG
Ermutigt durch meinen "Fake-SSG"-Ansatz, den ich vor ein paar Tagen nach i18n implementiert hatte, entschied ich mich für eine kleine, leichtgewichtige, maßgeschneiderte Lösung für Foonys SSG.
Mein "Fake-SSG"-Ansatz bestand darin, den Blogbeitrag-Inhalt von Seiten mit Blogbeiträgen zu ziehen (
/posts-Routen und Spielseiten) und sie genau dort zu platzieren, wo der Client sie rendern würde, speziell für Suchmaschinen und LLMs, um Foony besser zu verstehen. Es wurde auch ld+json-Schema und ein paar kleine SEO-Sachen angewendet.
Der Ansatz ist einfach:
- Auf der bestehenden React-SPA aufbauen: Keine Migration nötig, einfach SSG-Generierung zur Build-Zeit hinzufügen.
renderToReadableStreamverwenden: Reacts 18 Streaming-SSR-API behandelt Suspense nativ.- Statische HTML-Dateien generieren: Routen zur Build-Zeit vorrendern und als statische Dateien ausliefern, wobei wir unseren SitemapGenerator verwenden, um eine Liste von Routen zu bekommen.
- Minimale Änderungen an der bestehenden Codebasis: Die meisten Komponenten funktionieren so, wie sie sind.
Die Kernimplementierung lebt in client/src/generators/GenerateShellSsgFromSitemap.ts. Sie liest eine Sitemap, rendert jede Route mit Reacts renderToReadableStream und schreibt das HTML in statische Dateien. Einfach, genau wie ich es mag!
Das wurde auch ziemlich schnell. Etwa 2.800 Routen in 10 Sekunden gerendert. Schick. Das ist deutlich schneller als NextJS, Gatsby und Astro. <img alt="SSG-Konsolenausgabe mit benötigter Zeit" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Ich könnte stundenlang über Einfachheit sprechen. Auch wenn sie dir bei großen Unternehmen wegen "mangelnder Komplexität" keine Beförderung einbringt: Einfacher Code ist schön, wartbar und insgesamt viel besser für die Entwicklergeschwindigkeit. Das ist etwas, das ich an den Zen-Prinzipien wirklich bewundere.
Das Suspense-Boundary-Problem
Jetzt hatte ich also SSG, und der Inhalt tauchte im HTML auf, aber meine Seiten waren leer! Wie das?! <img alt="SSG leere Seite" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Es stellt sich heraus, dass renderToReadableStream immer noch Suspense-Grenzen hat, selbst wenn man await stream.allReady verwendet. Meine Vermutung ist, dass das daran liegt, dass es ein "Stream" ist und dafür gedacht ist, an Clients weitergegeben zu werden, sobald Bytes empfangen werden.
Was React ausgibt
Wenn man renderToReadableStream mit Suspense verwendet, gibt React HTML wie dieses aus:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Eigentlicher Inhalt hier -->
</div>
...
<script>/*Skript, das die Suspense-Grenzen ersetzt*/</script>
Das <template id="B:0"> ist ein Platzhalter, an den der Inhalt kommen soll. Das <div hidden id="S:0"> enthält den tatsächlich gerenderten Inhalt. Das B:0 passt per Nummer zum S:0 (0-basierter Index).
Ohne JavaScript würden Suchmaschinen (ich schaue dich an, Bing) und LLMs eine fast leere Seite mit nur dem Template-Platzhalter sehen. Das macht den ganzen Sinn von SSG zunichte!
Ich habe keinen sauberen Weg gefunden, diese Suspense-Grenzen zu entfernen, also war meine Lösung, ein paar Tests und eine resolveSuspenseBoundaries-Funktion zu schreiben, die diese austauscht. Das war schneller, als das HTML zu parsen und das Skript mit etwas wie JSDOM auszuführen. Und, was noch wichtiger ist: Es war eine Voraussetzung für das, was ich geplant hatte: eine schöne, lesbare Seite für Suchmaschinen / LLMs ohne JavaScript, aber mit Unterstützung für Suspense-Grenzen und Hydration auf dem Client.
Die Transformation testen
Ich begann damit, Tests für die Transformation zu schreiben, indem ich einige Beispiele aus dem DOM nahm: einmal von dem, was ich hatte (JavaScript deaktiviert), und einmal von dem, was ich wollte (JavaScript aktiviert). Ich habe diese in ein LLM eingespeist und es die Testgenerierung übernehmen lassen, etwas, worin es ziemlich gut ist.
Diese Tests leben in client/src/generators/ssr/renderRoute.test.ts und stellen sicher, dass die Transformation korrekt funktioniert. Die Tests decken ab:
- Einfache Boundary-Ersetzung (Blog-Listing)
- Komplexe Boundaries mit Inhalt zwischen Template und schließendem Kommentar
- Mehrere Boundaries
- Boundaries ohne Kommentarmarkierungen
- Edge Cases
Diese Art von "TDD" ist tatsächlich ziemlich nützlich für diesen Anwendungsfall, bei dem man erwartete Ein- und Ausgaben hat.
Das ist nicht zu verwechseln mit "TDD für alles, weil Robert C. Martin es gesagt hat" (was die Entwicklungsgeschwindigkeit deines Teams ausbremst). Du solltest TDD NICHT für UI oder Bereiche deines Codes verwenden, die sich ständig ändern!
Die Lösung: resolveSuspenseBoundaries
Jetzt, da die Tests vorhanden waren, ließ ich das LLM die Funktion für resolveSuspenseBoundaries schreiben. Ich habe cheerio dafür verwendet, um die Brüchigkeit von RegEx zu vermeiden, auch wenn die Verwendung von RegEx hier die SSG-Zeit um etwa 40 % reduzieren würde.
export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
const originalHtml = html;
const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];
// Collect hidden divs with their content and positions.
const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
$('div[hidden][id^="S:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const content = $(el).html() || '';
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset === 'number' && typeof endOffset === 'number') {
hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
}
});
if (hiddenDivs.size === 0) {
return {html: originalHtml, didResolveSuspense: false};
}
// Find templates (B:0) and replace them with the matching hidden content (S:0),
// following React’s internal $RV behavior.
$('template[id^="B:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const divInfo = hiddenDivs.get(boundaryId);
if (!divInfo) {
return;
}
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
return;
}
const templateIndex = startOffset;
const templateLength = endOffset - startOffset;
const afterTemplate = originalHtml.substring(templateIndex + templateLength);
const closingCommentMatch = afterTemplate.match(/<!--\/[amp;]-->/);
const removeEndIndex = closingCommentMatch
? templateIndex + templateLength + closingCommentMatch.index!
: templateIndex + templateLength;
const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1;
const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex);
const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex);
operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex});
operations.push({index: templateIndex, removeLength: 0, insertText: divContent});
operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex});
operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex});
operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex});
});
operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength));
let resultHtml = originalHtml;
for (const operation of operations) {
resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength);
}
return {html: resultHtml, didResolveSuspense: true};
}
Das stellt sicher, dass Suchmaschinen und LLMs anstelle einer fast leeren Seite eine vollständig gerenderte Seite sehen.
Jetzt haben wir SSG, das auch ohne JavaScript gut funktioniert!
<img alt="Kein JavaScript SSG für Foonys Blogs" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />
Langfristig ist es möglich, dass React sein Suspense-Format ändert. Vielleicht entferne ich den Suspense-Auflösungscode, sobald ich eine bessere Lösung für die Seiten habe, die lazy-geladen sind (und somit Suspense-Grenzen benötigen).
Hydration-Strategie (Update: Das hat 3 Tage + 1 zusätzlichen Tag gedauert)
Hydration ist knifflig. Das wusste ich. Aber nach ein bisschen Arbeit habe ich es zum Laufen gebracht!
Gesamtzeit für Hydration: 3 Tage, plus 1 zusätzlicher Tag, um den Dehydrierungsansatz zu ersetzen.
Der trickreichste Teil war einfach, diese erste minimale, funktionierende Hydration hinzubekommen. Sobald ich es geschafft hatte, ein "Hello World" mit der Navbar zu rendern, gewann ich das Vertrauen, dass das hier vielleicht doch nicht einen ganzen Monat dauern würde!
<img alt="Foonys Hello World hydratisiert erfolgreich mit Navbar" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Für diese erste minimale, funktionierende Hydration hatte ich eine besondere Herausforderung: Ich wollte Hydration, aber ich wollte auch gutes SEO für Suchmaschinen und LLMs, ohne dass Entwickler über Suspense-Grenzen nachdenken müssen.
Die Herausforderung
React-Hydration ist extrem wörtlich: Wenn das DOM nicht so aussieht, wie React es für das erste Rendering erwartet, bekommst du diese schöne, fast nutzlose Fehlermeldung in deiner Konsole, und React wirft alles weg und rendert von Grund auf neu. Nicht einmal ein Diff, das dir sagt, was schiefgegangen ist!
In unserem Fall hat SSG das auf mehrere Arten verschlimmert:
- Wir haben das HTML nachbearbeitet, um die Streaming-Suspense-Artefakte von React 18 zu entfernen / aufzulösen (was großartig für Bots ist).
- Der Client hatte zum Zeitpunkt (t = 0) nicht immer genau die gleichen Daten zur Verfügung wie das Server-Rendering (SSG-Daten, Blog-Metadaten usw.).
- Unser i18n ist standardmäßig "lazy", was bedeutet, dass beim ersten Rendern Übersetzungen fehlen können, es sei denn, man zeichnet auf, welche Übersetzungen für SSG verwendet wurden, und injiziert sie, bevor React rendert.
Was funktionierte (Erster Ansatz: Dehydrierung)
Zuerst habe ich etwas Cleveres und Süßes versucht: Ich habe ein Command-Pattern verwendet, um die Befehle aufzuzeichnen, die zum Auflösen der Suspense-Grenzen des HTML verwendet wurden, und gab die umgekehrten Transformationsbefehle zurück, damit ich das HTML in den Zustand zurückversetzen konnte, den React für die Hydration benötigt.
Meine Hoffnung war, dass ich mit dieser Befehlsmethode deutlich weniger Bytes in der index.html ausliefern könnte. Aber wie bei den meisten cleveren Lösungen ist das gescheitert, weil Browser das HTML auf subtile Weise modifizieren, etwa indem sie ein ; oder / entfernen oder hinzufügen, was die Ersetzungsindizes durcheinanderbrachte.
Technisch könnte man diese subtilen Browseränderungen wahrscheinlich berücksichtigen, aber ich war nicht bereit, etwas so Brüchiges auszuliefern.
Anstatt zu versuchen, die Suspense-Boundary-Transformation zurück in Reacts Streaming-Markup zu "umzukehren", habe ich etwas super Einfaches gemacht:
Das originale, unaufgelöste HTML in einem <script type="text"> mitliefern.
Dieser "Dehydrierungs"-Ansatz funktionierte, aber ich habe einen zusätzlichen Tag damit verbracht, ihn durch eine bessere Lösung zu ersetzen.
Der bessere Ansatz: Critical-Path-Suspense-Boundary-Ersetzung
Nach der ersten Implementierung hatte ich immer noch ein paar Probleme mit Suspense-Grenzen. Da wurde mir klar, dass es eine sauberere, bessere, einfachere Lösung gibt. Ich ersetzte den Dehydrierungsansatz durch Critical-Path-Suspense-Boundary-Ersetzung, die:
- Den Critical Path vor der Hydration lädt: Komponenten, die während des SSR vorgeladen wurden, werden identifiziert und auf dem Client vorgeladen, bevor
hydrateRoot aufgerufen wird
- Einfacher zu warten ist: Keine React-Internals oder AST-Parsing erforderlich (der Dehydrierungsansatz musste HTML parsen und wiederherstellen)
- Weniger Bytes ausliefert: Wir bündeln die ursprüngliche SSR-Antwort von React nicht mehr in einem Skript-Tag
- Einen potenziellen Flash verhindert: Keine Notwendigkeit, HTML zu dehydrieren / rehydratisieren, was einen potenziellen visuellen Flash eliminiert
Die Implementierung verfolgt, welche Lazy-Komponenten während des SSR vorgeladen wurden (über SSRLazyComponentTracker), schließt deren Importpfade in die Hydration-Daten ein und lädt sie synchron vor der Hydration vor. Critical-Path-Komponenten rendern direkt ohne Suspense-Grenzen und entsprechen exakt der SSR-Ausgabe.
Für alles andere lassen wir das erste Client-Rendering so wirken wie SSR / SSG. Das bedeutet, die gleichen Eingaben zu verwenden und diese Eingaben synchron vor hydrateRoot verfügbar zu machen. Das geschieht durch Bündelung über unsere "ssg-data".
Konkret waren die Anpassungen:
SSR-Eingaben in ein einziges Text-Skript bündeln
- Während des SSG injizieren wir ein
<script type="text/foony-ssg" id="foony-ssg-data">...</script> direkt vor dem Vite-Modul-Einstiegspunkt.
- Dieses Skript enthält:
html: das aufgelöste HTML, das wir tatsächlich in der statischen Datei ausgeliefert haben
ssgData: die serialisierten SSGData, die vom SSR-Wrapper verwendet werden. Ich plane, das auf einen Proxy oder Ähnliches umzustellen, sodass nur Daten, auf die zugegriffen wird, enthalten sind.
translationData: die Übersetzungs-Key-Value-Blobs, die wir während des SSR berührt haben
Diese Eingaben direkt vor der Hydration injizieren
- In
main.tsx machen wir synchron Folgendes:
#root.innerHTML auf das serialisierte aufgelöste HTML setzen (sodass das DOM genau das ist, was die Hydration sieht)
- die App in
SSGDataProvider einwickeln, sodass Komponenten beim ersten Rendern die gleichen SSGData haben
i18n sofort verfügbar machen, indem Übersetzungswerte injiziert werden
- Wir zeichnen die tatsächlichen Übersetzungsobjekte auf, auf die während des SSR zugegriffen wurde, und liefern sie im SSG-Skript aus.
- Auf dem Client injizieren wir sie direkt in den Cache von
LocaleQueryer über eine spezielle LocaleQueryer.inject()-Methode, sodass Übersetzungen sofort verfügbar sind.
Und damit hat das erste Rendering die gleichen Daten, die SSR hatte!
Der useIsSSRMode()-Hook ist bereits in client/src/generators/ssr/isSSRMode.ts implementiert:
export function useIsSSRMode(): boolean {
const [isSSRMode, setIsSSRMode] = React.useState(true);
React.useEffect(() => {
// After mount (hydration complete), switch to client mode
setIsSSRMode(false);
}, []);
return isSSRMode;
}
Dieser Hook gibt während des SSR und beim ersten Client-Rendering (Hydration) true zurück und wechselt dann nach dem Mount zu false. Komponenten wie UserBanner, Navbar und Dialog verwenden das bereits, um Hydration-Mismatches zu vermeiden.
- React für bessere Diffs patchen
Ich hatte gehofft, ich könnte einfach hydration-overlay verwenden. Aber es wird nicht aktiv gepflegt, nur bis React 18 unterstützt und war nicht produktionsreif. Also ließ ich ein LLM das Repo zur Inspiration klonen, und dann erstellte es in wenigen Minuten ein minimales Hydration-Overlay. Ich brauchte nichts Schickes, nur etwas, das während der Entwicklung auftaucht, damit ich herausfinden konnte, wo etwas schiefging.
Dieses neue Overlay ist super basic, also sind die Diffs nicht ganz perfekt. React entfernt Kommentare, fügt ;s nach Style-Attributen hinzu, modifiziert Whitespace und ein paar andere kleine Dinge, die unser Overlay (noch) nicht berücksichtigt. Unser Overlay enthält auch HTML-Kommentare, die React für seine Hydration ignoriert.
<img alt="Unser neues Hydration-Overlay" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />
Aber es reicht aus, um herauszufinden, was repariert werden muss.
<img alt="Diff unseres SSG vs. Client-First-Page-Rendering für React-Hydration" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />
In Zahlen
Um dir ein Gefühl dafür zu geben, was diese Implementierung umfasste:
- 2 Tage Arbeit (von Anfang bis funktionierendem SSG). Das waren etwas mehr als 24 Stunden im Urlaub.
- 4 Tage Arbeit, um die Hydration sauber zum Laufen zu bringen, ohne dass asynchrone Übersetzungs-Races oder
useMediaQuery Sachen durcheinanderbringen.
- 1 zusätzlicher Tag, um den Dehydrierungsansatz durch Critical-Path-Suspense-Boundary-Ersetzung zu ersetzen (einfacher, weniger Bytes, kein potenzieller Flash).
- ~200 Zeilen Kerncode für die SSG-Generierung (
GenerateShellSsgFromSitemap.ts)
- ~120 Zeilen für die Auflösung von Suspense-Grenzen (
resolveSuspenseBoundaries in renderRoute.tsx). Hinweis: Dies wurde später durch den Critical-Path-Ansatz ersetzt
- ~50 Zeilen SSR-Utilities (
isSSRMode.ts)
- ~100 Zeilen Tests (
renderRoute.test.ts)
- ~150 Zeilen Polyfills für SSR (
setupSSREnvironment)
- Minimale Änderungen an bestehenden Komponenten (hauptsächlich Hinzufügen von
useIsSSRMode()-Checks)
Die Lösung ist leichtgewichtig und wartbar. Sie erfordert keine Framework-Migration und funktioniert mit unserer bestehenden React-SPA.
Wichtigste Erkenntnisse
Manchmal ist eine maßgeschneiderte Lösung besser
Nicht jedes Problem braucht ein Framework. Für Foony war eine kleine, maßgeschneiderte SSG-Lösung die richtige Wahl. Sie ist:
- Leichtgewichtig: Keine schweren Abhängigkeiten oder Framework-Overhead
- Wartbar: Einfacher Code, den wir verstehen
- Flexibel: Einfach zu modifizieren und nach Bedarf zu erweitern
- Kompatibel: Funktioniert mit unserer bestehenden React-SPA ohne Migration
Reacts Streaming-SSR hat Eigenheiten
Reacts renderToReadableStream ist nett für den Umgang mit Suspense, aber es hat Eigenheiten. Auch mit await stream.allReady bekommt man immer noch Suspense-Grenzen in der Ausgabe. Das ist kein Bug, sondern so vorgesehen für Streaming. Aber für SSG brauchen wir vollständig aufgelöstes HTML. Es fühlt sich wie ein Versäumnis des React-Teams an, dieses Szenario nicht sauber zu handhaben.
Meine Lösung war, das HTML nachzubearbeiten und Grenzen aufzulösen. Es ist nicht hübsch, aber es ist schnell und flexibel genug für meinen Anwendungsfall.
TDD kann für LLMs nützlich sein
HTML-Transformation ist fehleranfällig. Ein kleiner Bug, und du könntest die gesamte SSG-Ausgabe kaputt machen und das Endbenutzererlebnis ruinieren. Ich ließ ein LLM umfassende Tests schreiben (mit meinen Vorgaben), um sicherzustellen, dass die Transformation korrekt funktioniert.
Fazit
SSG funktioniert jetzt für Foony. Seiten werden vollständig für Suchmaschinen und LLMs gerendert, und die Lösung ist wartbar und leichtgewichtig. Die Hydration für die SSG-Routen hat länger gedauert, als ich erwartet hatte (3 Tage), und ich habe einen zusätzlichen Tag damit verbracht, den anfänglichen Dehydrierungsansatz durch Critical-Path-Suspense-Boundary-Ersetzung zu ersetzen. Der neue Ansatz ist einfacher zu warten, liefert weniger Bytes aus und verhindert potenzielle visuelle Flashes durch das Dehydrieren / Rehydratisieren von HTML.
Ich bin immer noch fassungslos, dass es nur 2 Tage gedauert hat, eine maßgeschneiderte Lösung für SSG zu implementieren. Aber manchmal ist die richtige Lösung die einfachste.
Zukünftige Arbeiten umfassen die Vervollständigung des Hydration-Matchings und potenzielles Patchen von React für besseres Debugging. Aber für den Moment hat Foony funktionierendes SSG. Ich werde in den kommenden Wochen ein Auge auf Google Search Console und Bing Webmaster Tools haben, um zu sehen, welche Auswirkungen das auf unser SEO hat.