diff --git a/apps/web/app/api/tweet/[id]/route.ts b/apps/web/app/api/tweet/[id]/route.ts new file mode 100644 index 0000000..a752e12 --- /dev/null +++ b/apps/web/app/api/tweet/[id]/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import { getTweet } from 'react-tweet/api'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + try { + const tweet = await getTweet(id); + + if (!tweet) { + return NextResponse.json({ error: 'Tweet not found' }, { status: 404 }); + } + + return NextResponse.json({ data: tweet }); + } catch (error) { + console.error('Error fetching tweet:', error); + return NextResponse.json({ error: 'Failed to fetch tweet' }, { status: 500 }); + } +} diff --git a/apps/web/app/blog/[slug]/page.tsx b/apps/web/app/blog/[slug]/page.tsx index 2cb1fb1..13fc2d3 100644 --- a/apps/web/app/blog/[slug]/page.tsx +++ b/apps/web/app/blog/[slug]/page.tsx @@ -8,8 +8,7 @@ import { Reveal } from "../../../src/components/Reveal"; import { BlogPostClient } from "../../../src/components/BlogPostClient"; import { TextSelectionShare } from "../../../src/components/TextSelectionShare"; import { BlogPostStickyBar } from "../../../src/components/blog/BlogPostStickyBar"; -import { MDXRemote } from "next-mdx-remote/rsc"; -import { MDXComponents } from "../../../mdx-components"; +import { MDXContent } from "../../../src/components/MDXContent"; export async function generateStaticParams() { return allPosts.map((post) => ({ @@ -74,6 +73,7 @@ export default async function BlogPostPage({ date={formattedDate} readingTime={readingTime} slug={slug} + thumbnail={post.thumbnail} />
@@ -99,10 +99,7 @@ export default async function BlogPostPage({ )}
- +
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index cf8da94..6c85822 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -33,10 +33,6 @@ export default function RootLayout({ }) { return ( - - - -
diff --git a/apps/web/content/blog/google-pagespeed-guide-warum-ladezeit-ihr-wichtigster-b2b-umsatzhebel-ist.mdx b/apps/web/content/blog/google-pagespeed-guide-warum-ladezeit-ihr-wichtigster-b2b-umsatzhebel-ist.mdx new file mode 100644 index 0000000..d7bec97 --- /dev/null +++ b/apps/web/content/blog/google-pagespeed-guide-warum-ladezeit-ihr-wichtigster-b2b-umsatzhebel-ist.mdx @@ -0,0 +1,221 @@ +--- +title: "Google PageSpeed Guide: Warum Ladezeit Ihr wichtigster B2B-Umsatzhebel ist" +thumbnail: "/blog/der-google-pagespeed-guide-warum-ladezeit-ihr-wichtigster-umsatzhebel-ist.png" +description: "Millisekunden entscheiden im B2B über Erfolg oder Absprung. Erfahren Sie, wie Core Web Vitals Ihre Conversion-Rate und SEO-Rankings massiv beeinflussen." +date: "2026-02-15" +tags: ["performance", "seo", "conversion-optimization"] +--- + + + Unternehmen investieren oft Unsummen in glänzende Oberflächen, während das technische Fundament einer digitalen Ruine gleicht. Wenn Ihre Website bei Google PageSpeed scheitert, verlieren Sie Kunden – bevor diese Ihre Botschaft überhaupt wahrnehmen können. + + + + In der digitalen Ökonomie ist Performance kein „Nice-to-have“, sondern die Basis jeder Customer Journey. Google fand heraus, dass 53 % der mobilen Website-Besucher eine Seite verlassen, die länger als drei Sekunden zum Laden benötigt. + + + + +**TL;DR:** Website-Geschwindigkeit ist ein kritischer Ranking-Faktor und der stärkste Hebel für Conversions. Während Page-Load-Zeiten von 1s auf 3s die Bounce-Rate um 32 % erhöhen, steigert jede 0,1s Verbesserung die Conversion um bis zu 8,4 %. Moderne Architekturen wie Static Site Generation sind der Schlüssel zur Performance-Exzellenz. + +

Der unsichtbare Umsatz-Verschleiß

+ + + Stellen Sie sich vor, Sie eröffnen ein Luxus-Geschäft in der besten Lage, aber die Eingangstür klemmt massiv. Kunden müssen 10 Sekunden lang drücken, um einzutreten. Genau das passiert täglich auf B2B-Websites, deren technische Altlasten die Nutzererfahrung ersticken. [Langsame Ladezeiten](/blog/slow-loading-costs-customers) sind heute der Hauptgrund für hohe Absprungraten und sinkende Sichtbarkeit. 47 % der Nutzer erwarten laut Akamai, dass eine Webseite in zwei Sekunden oder weniger lädt. + + + + + + Millisekunden sind im digitalen Zeitalter die härteste Währung. Daten von Google zeigen: Steigt die Ladezeit von einer auf drei Sekunden, erhöht sich die Wahrscheinlichkeit eines Absprungs um 32 %. Es ist ein gnadenloser Zusammenhang: Je länger der Browser wartet, desto geringer die Wahrscheinlichkeit eines profitablen Abschlusses. Geht die Ladezeit sogar auf 5 Sekunden hoch, steigt die Bounce-Wahrscheinlichkeit laut Google Developers sogar um 90 %. + + + + + + Ich betrachte Performance nicht als isolierte IT-Kennzahl, sondern als ökonomischen Hebel. Ein [professionelles Hosting](/blog/professional-hosting-operations) und eine saubere Architektur sind die Mindestanforderung. Websites, die in einer Sekunde laden, haben eine fast dreimal höhere Conversion-Rate als Seiten, die fünf Sekunden benötigen. Eine Studie von Portent untermauert dies: Die Conversion-Rate sinkt im Schnitt um 4,42 % mit jeder zusätzlichen Sekunde Ladezeit (zwischen Sekunde 0 und 5). + + +

Core Web Vitals: Die neuen Spielregeln von Google

+ + + Google hat bestätigt, dass Core Web Vitals als Ranking-Signale für Suchergebnisse genutzt werden. Wer hier rote Zahlen schreibt, wird vom Algorithmus abgestraft. Webseiten, die den Schwellenwert „Gut“ in allen Kategorien erreichen, verzeichnen im Schnitt **24 % weniger Nutzer-Abbrüche**. Dennoch zeigt die Forschung, dass viele mobile Seiten fast sieben Sekunden brauchen, bis der Content "above the fold" angezeigt wird. + + + + + + Dabei fokussiert sich das Framework auf drei wesentliche Säulen der User Experience: + + + + + Largest Contentful Paint (LCP): Ladegeschwindigkeit des Hauptinhalts (Ziel: unter 2,5s). + + + Interaction to Next Paint (INP): Die neue Metrik für Interaktivität und Reaktionsschnelligkeit (Ziel: unter 200ms). + + + Cumulative Layout Shift (CLS): Verhindert nerviges Hin- und Herspringen von Inhalten (Ziel: unter 0,1). + + + +

Warum klassische CMS-Lösungen scheitern

+ + + Die Ursache für mangelhafte Performance liegt oft in „All-in-One“-Lösungen. [Die versteckten Kosten von WordPress-Plugins](/blog/hidden-costs-of-wordpress-plugins) offenbaren sich spätestens beim ersten Audit. Jedes zusätzliche Plugin erhöht potenziell die Latenz und reduziert die Kundenzufriedenheit – laut Aberdeen Group kann bereits eine Sekunde Verzögerung die Zufriedenheit um 16 % senken. + + +
+ +
+ + + Jedes Byte muss durch das Nadelöhr der mobilen Verbindung. Wer hier auf [Standard-Templates setzt](/blog/why-no-templates-matter), sabotiert seinen Erfolg. 79 % der Shopper, die mit der Performance unzufrieden sind, kaufen laut Kissmetrics weniger wahrscheinlich erneut auf derselben Seite. Oft ersticken [Baukasten-Systeme Ihre Unabhängigkeit](/blog/builder-systems-threaten-independence) durch unnötigen Code-Overhead. + + +
+ +graph TD + A["Anfrage Browser"] --> B["Server Rechenlast"] + B --> C["DB Abfragen"] + C --> D["HTML Generierung"] + D --> E["Rendering Blocks"] + E -- "3-5 Sek" --> F["Sichtbar"] + +
+ +

Meine Architektur der Geschwindigkeit

+ + + Ich verfolge einen [Build-First Ansatz](/blog/build-first-digital-architecture). Statt die Seite erst mühsam zusammenzubauen, wenn der Kunde sie anfragt, liefere ich fertig optimierte statische Ressourcen aus einem globalen Edge-Netzwerk. Dies minimiert die Time to First Byte (TTFB) radikal, oft auf unter 50ms. + + +
+ +
+ + + Das Resultat ist Skalierbarkeit durch Design. Die Antwortzeit bleibt konstant niedrig, egal ob 10 oder 10.000 Nutzer gleichzeitig zugreifen. Dies ist ein entscheidender Wettbewerbsvorteil, da viele B2B-Wettbewerber noch immer erhebliche Defizite beim LCP aufweisen. Eine [Wartungsfreie Architektur](/blog/maintenance-for-headless-systems) sorgt zudem dafür, dass diese Performance über Jahre stabil bleibt. + + +

Die drei Säulen der Umsetzung

+ + + +

Der „Haken“ an der Sache: Devil's Advocate

+ + + Ehrlichkeit gehört zu einer profunden Architektur-Beratung. Eine High-End-Performance-Lösung ist kein "Plug-and-Play" und erfordert Investitionen in Expertise. + + + + + Komplexität: Der Build-Prozess ist technisch anspruchsvoller als ein einfaches FTP-Upload eines PHP-Skripts. + + + Initialaufwand: Höhere Setup-Kosten im Vergleich zum 08/15 Standard-Template. + + + Langfristiger ROI: Die Investition amortisiert sich durch höhere Conversions und sinkende Kosten pro Lead massiv. + + + + + +
+ +
+ +

Der wirtschaftliche Case

+ + + B2B-Unternehmen verlieren laut Google massiv Conversions pro zusätzlicher Sekunde Ladezeit. Wenn Sie Budget in Marketing investieren, aber Leads durch technische Altlasten verlieren, verbrennen Sie Kapital. [Clean Code](/blog/clean-code-for-business-value) ist hier kein Selbstzweck, sondern eine ökonomische Notwendigkeit. Walmart sah bis zu 2 % mehr Conversions für jede Sekunde Ladezeit-Optimierung. + + + + + + Mein System fungiert als ROI-Beschleuniger für Ihren gesamten digitalen Auftritt. Warum viele Agenturen bei diesem Thema scheitern, erkläre ich im Detail in meinem Artikel [Warum Ihre Agentur für kleine Änderungen Wochen braucht](/blog/why-agencies-are-slow). + + + + +

Fazit: Respekt vor der Zeit Ihrer Nutzer

+ + + Geschwindigkeit ist letztlich Ausdruck von Wertschätzung. Sie signalisieren Ihrem Kunden: „Ich respektiere deine Zeit.“ Ein technisch überlegenes System ist im B2B-Sektor heute kein Bonus mehr, sondern die Eintrittskarte in den Markt. Jede Millisekunde, die Sie einsparen, ist eine direkte Investition in Ihre Conversion-Rate und Ihre Search Visibility. + + + + Lassen Sie uns Ihre Website in eine hochpräzise Wachstums-Maschine verwandeln, die nicht nur hochwertig aussieht, sondern auf Knopfdruck liefert. Qualität zahlt sich aus – messbar in Sekunden und in Euro. + + +
+ +
+ +*** + + +

Warum ist mein PageSpeed-Score mobil oft deutlich schlechter als auf dem Desktop?

+ + Mobile Geräte haben begrenzte Rechenleistung und oft instabile Funkverbindungen. Eine optimierte Architektur reduziert JavaScript-Last (INP) und komprimiert Assets radikal, um diese Hardware-Beschränkungen auszugleichen. + + +

Reicht ein Caching-Plugin für WordPress nicht aus?

+ + Plugins kurieren nur Symptome der Server-Reaktionszeit, lösen aber nicht das Problem von zu viel Code-Ballast im Browser. Für echte Spitzenwerte und stabile Core Web Vitals ist eine schlanke, vom CMS entkoppelte Architektur notwendig. + + +

Wie beeinflusst Performance direkt mein SEO-Ranking?

+ + Google nutzt Core Web Vitals als explizite Ranking-Signale. Seiten mit exzellenter Performance werden bevorzugt indexiert und positioniert, da sie eine bessere User Experience bieten, was Google durch höhere Sichtbarkeit belohnt. + +
\ No newline at end of file diff --git a/apps/web/content/blog/test-mermaid.mdx b/apps/web/content/blog/test-mermaid.mdx deleted file mode 100644 index 74eb9ef..0000000 --- a/apps/web/content/blog/test-mermaid.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: "Test Mermaid" -description: "Testing Mermaid rendering" -date: "2026-01-01" -tags: ["test"] ---- - -Test 1: Plain string attribute (NO quotes in graph content): - - -graph TD - A-->B - B-->C - - -Test 2: Children as raw text (no template literal): - - -graph TD - D-->E - E-->F - diff --git a/apps/web/content/blog/why-pagespeed-fails.mdx b/apps/web/content/blog/why-pagespeed-fails.mdx deleted file mode 100644 index d8dd7ec..0000000 --- a/apps/web/content/blog/why-pagespeed-fails.mdx +++ /dev/null @@ -1,191 +0,0 @@ ---- -title: "Warum Ihre Website bei Google PageSpeed scheitert" -description: "Millisekunden entscheiden über Ihren Umsatz: So optimieren Sie Ihre Web-Performance für maximale Conversion." -date: "2026-02-15" -tags: ["performance", "seo"] ---- - - - Unternehmen investieren oft Unsummen in glänzende Oberflächen, während das - technische Fundament einer digitalen Ruine gleicht. - - - Wenn Ihre Website bei Google PageSpeed scheitert, verlieren Sie Kunden –{" "} - bevor diese Ihre Botschaft überhaupt wahrnehmen können. - - - In meiner Arbeit als Digital Architect ist die Geschwindigkeit der - architektonische Gradmesser für Professionalität. - - -

Der unsichtbare Umsatz-Verschleiß

- - Stellen Sie sich vor, Sie eröffnen ein Luxus-Geschäft in der besten Lage, - aber die Eingangstür klemmt massiv. - - - Kunden müssen 10 Sekunden lang drücken, um einzutreten. Genau das passiert - täglich auf tausenden Websites. - - - Millisekunden sind im digitalen Zeitalter die härteste Währung. - - - Eine Verzögerung von nur einer Sekunde kann die{" "} - Conversion-Rate um bis zu 20 % senken. Das ist kein - technisches Detail, sondern ein unternehmerisches Risiko. - - - Ich betrachte Performance nicht als IT-Kennzahl, sondern als ökonomischen - Hebel. - - - Google bewertet Websites heute primär nach den "Core Web Vitals". Das sind - präzise Messgrößen für die Frustrationstoleranz Ihrer Nutzer. - - - Wer hier rote Zahlen schreibt, wird vom Algorithmus unsichtbar gemacht – - eine digitale Strafe für technische Nachlässigkeit. - - -
- -
- -

Warum klassische Lösungen scheitern

- - Die Ursache liegt oft in der Verwendung von "All-in-One"-Lösungen wie - WordPress oder überladenen Baukästen. - - - Diese Systeme versuchen, alles für jeden zu sein. Das Ergebnis ist ein - gigantischer "Ballast an Code". - - - Jedes Byte muss durch das Nadelöhr der Internetverbindung gepresst werden, - bevor das erste Bild erscheint. - - - In einer mobilen Welt mit oft instabilen Verbindungen ist das ein{" "} - architektonisches Todesurteil. Wer hier spart, zahlt - später doppelt durch verlorene Kunden. - - -
- -graph TD - A["Anfrage des Browsers"] --> B["Server muss nachdenken (PHP/DB)"] - B --> C["Hunderte Datenbank-Abfragen"] - C --> D["HTML wird mühsam live konstruiert"] - D --> E["Veraltetes Asset-Management lädt alles"] - E --> F["Render-Blocking Code (Browser stoppt)"] - F --> G["Seite endlich sichtbar (nach 3-5 Sek)"] - style B fill:#fca5a5,stroke:#333 - style F fill:#fca5a5,stroke:#333 - style G fill:#fca5a5,stroke:#333 - -
- Der Flaschenhals der Standard-Systeme: Rechenzeit am Server raubt Ihnen - wertvolle Kundenzeit. -
-
- -

Meine Architektur der Geschwindigkeit

- - Ich verfolge einen radikal anderen Ansatz. Statt die Seite erst mühsam - zusammenzubauen, wenn der Kunde sie anfragt, liefere ich fertig optimierte - "digitale Objekte" aus. - - - Mein "Static-First" Framework sorgt dafür, dass die Antwortzeit Ihres - Servers nahezu bei Null liegt. - - - Völlig egal, ob gerade 10 oder 10.000 Menschen gleichzeitig auf Ihre Seite - zugreifen. - - - Das ist Skalierbarkeit durch Design, nicht durch bloße - Server-Power. - - -

Die drei Säulen meiner Umsetzung

- - - Zero-Computation am Edge: Durch Static Site Generation - (SSG) liegen alle Inhalte fertig auf globalen CDNs. Keine Wartezeit. - - - Präzises Asset-Engineering: Ich nutze Tree-Shaking. Ihr - Kunde lädt exakt nur den Code, den er wirklich benötigt. - - - Next-Gen Media-Handling: Bilder werden automatisch in - Formaten wie AVIF ausgeliefert. Qualität bleibt, Dateigröße schmilzt. - - - -
- -pie - "JavaScript Execution" : 35 - "Render Blocking CSS" : 25 - "Server Response Time" : 20 - "Image Loading" : 15 - "Third-Party Scripts" : 5 - -
- Wo die Zeit wirklich verloren geht: Eine Analyse der häufigsten Ladezeit-Killer. -
-
- -

Der wirtschaftliche Case

- - Baukästen wirken "auf den ersten Blick" günstiger. Doch das ist eine - riskante Milchmädchenrechnung. - - - Wenn Sie monatlich 5.000 € in Marketing investieren, aber 30 % Ihrer Leads - durch Ladezeiten verlieren, verbrennen Sie jedes Jahr 18.000 €. - - - Mein System ist kein Kostenfaktor, sondern ein{" "} - ROI-Beschleuniger. - - - Wir senken die Kosten pro Lead, indem wir die Reibungsverluste minimieren. - Ein technisch überlegenes System ist immer die rentablere Wahl. - - -

Wann meine Architektur für Sie Sinn macht

- - Ich bin Partner für Unternehmen, die über die "digitale Visitenkarte" - hinausgewachsen sind. - - - Ist Ihre Website ein geschäftskritisches Werkzeug für die Lead-Gen? Dann - ist mein Ansatz alternativlos. - - - Ich steige dort ein, wo technologische{" "} - Exzellenz zum entscheidenden Wettbewerbsvorteil wird. - - -

Fazit: Respekt vor der Zeit Ihrer Nutzer

- - Geschwindigkeit ist letztlich Ausdruck von Wertschätzung. Sie - signalisieren Ihrem Kunden: "Ich respektiere deine Zeit." - - - Lassen Sie uns Ihre Website in eine hochpräzise Wachstums-Maschine - verwandeln. - - - Qualität zahlt sich aus – in Millisekunden und in Euro. - diff --git a/apps/web/contentlayer.config.ts b/apps/web/contentlayer.config.ts index 993105c..0071624 100644 --- a/apps/web/contentlayer.config.ts +++ b/apps/web/contentlayer.config.ts @@ -9,6 +9,7 @@ export const Post = defineDocumentType(() => ({ date: { type: 'string', required: true }, description: { type: 'string', required: true }, tags: { type: 'list', of: { type: 'string' }, required: true }, + thumbnail: { type: 'string', required: false }, }, computedFields: { slug: { diff --git a/apps/web/docs/AUDIENCE.md b/apps/web/docs/AUDIENCE.md new file mode 100644 index 0000000..2aa6672 --- /dev/null +++ b/apps/web/docs/AUDIENCE.md @@ -0,0 +1,33 @@ +# Zielgruppe + +## Primäre Zielgruppe: Der deutsche Mittelstand + +Unsere Blog-Inhalte richten sich an Entscheider in kleinen und mittelständischen Unternehmen – nicht an Entwickler. + +### Wer liest das? + +- **Geschäftsführer** von Handwerksbetrieben, Kanzleien, Arztpraxen +- **Marketing-Verantwortliche** in mittelständischen Unternehmen (10–250 Mitarbeiter) +- **Online-Shop-Betreiber** mit 20k–500k€ Jahresumsatz +- **Selbstständige** und Freiberufler mit eigener Website + +### Was sie NICHT sind + +- Keine Entwickler (kein Code-Jargon) +- Keine Enterprise-Konzerne (kein "Amazon-Scale", keine "Millionen User") +- Keine Marketing-Agenturen (kein Buzzword-Bingo) + +### Wie wir schreiben + +- **Know-how transportieren** ohne zu dozieren +- **Technische Fakten verständlich machen** — ELI5 aber nicht herablassend +- **Realistische Beispiele**: "Tischlerei Müller mit 30 Seitenbesuchern am Tag", nicht "globaler Marktführer mit 10M MAU" +- **Probleme benennen** die sie kennen: langsame Website, schlechtes Google-Ranking, verlorene Anfragen +- **Lösungen zeigen** die greifbar sind: konkrete Vorher/Nachher-Vergleiche, echte Zahlen + +### Tonalität gegenüber dem Leser + +- Auf Augenhöhe, nie von oben herab +- Wie ein kompetenter Bekannter der einem beim Thema Website hilft +- Ehrlich über Probleme, ohne Panik zu machen +- Kein Verkaufsdruck, keine künstliche Dringlichkeit diff --git a/apps/web/docs/CONTENT_RULES.md b/apps/web/docs/CONTENT_RULES.md new file mode 100644 index 0000000..6b14635 --- /dev/null +++ b/apps/web/docs/CONTENT_RULES.md @@ -0,0 +1,63 @@ +# Content-Regeln für Blog-Post-Generierung + +## 1. Visuelle Balance + +- **Max 1 visuelle Komponente pro 3–4 Textabsätze** +- Visualisierungen dürfen **niemals direkt hintereinander** stehen +- Zwischen zwei visuellen Elementen müssen mindestens 2 Textabsätze liegen +- Nicht mehr als 5–6 visuelle Komponenten pro Blog-Post insgesamt + +### Erlaubte visuelle Komponenten +- `Mermaid` / `DiagramFlow` / `DiagramSequence` — für Prozesse und Architektur +- `ArticleMeme` — echte Meme-Bilder (memegen.link), kurze und knackige Texte +- `BoldNumber` — einzelne Hero-Statistik mit Quelle +- `PremiumComparisonChart` / `MetricBar` — für Vergleiche +- `WebVitalsScore` — für Performance-Audits (max 1x pro Post) +- `WaterfallChart` — für Ladezeiten-Visualisierung (max 1x pro Post) +- `StatsGrid` — für 2–4 zusammengehörige Statistiken +- `ComparisonRow` — für Vorher/Nachher-Vergleiche + +### Verboten +- `MemeCard` (text-basierte Memes) — nur echte Bild-Memes verwenden +- AI-generierte Bilder im Content — nur Thumbnails erlaubt +- `DiagramPie` — vermeiden, zu generisch + +## 2. Zahlen und Statistiken + +- **Niemals nackte Zahlen** — jede Statistik braucht Kontext und Vergleich +- `BoldNumber` nur für DIE eine zentrale Statistik des Abschnitts +- Mehrere Zahlen → `StatsGrid` oder `PremiumComparisonChart` verwenden +- Jede Zahl braucht eine **Quelle** (`source` + `sourceUrl` Pflicht) +- Vergleiche sind immer besser als Einzelwerte: "33% vs. 92%", nicht nur "92%" + +## 3. Zitate und Quellen + +- Alle Zitate brauchen klare Attribution: `author`, `source`, `sourceUrl` +- Bei übersetzten Zitaten: "(übersetzt)" im Zitat oder als Hinweis +- `ExternalLink` für alle externen Referenzen im Fließtext +- Keine erfundenen Zitate — nur verifizierbare Quellen + +## 4. Mermaid-Diagramme + +- **Extrem kompakt halten**: Strikt max 3–4 Nodes pro Diagramm +- **Ausschließlich vertikale Layouts** (TD) — besser für Mobile +- Deutsche Labels verwenden +- Keine verschachtelten Subgraphs +- Jedes Diagramm braucht einen aussagekräftigen `title` + +## 5. Memes + +- Nur echte Bilder via `ArticleMeme` (memegen.link API) +- **Extreme Sarkasmus-Pflicht** — mach dich über schlechte Agentur-Arbeit oder Legacy-Tech lustig +- **Kurze, knackige Captions** — max 6 Wörter pro Zeile +- Deutsche Captions verwenden +- Bewährte Templates: `drake`, `disastergirl`, `fine`, `daily-struggle` +- Max 2–3 Memes pro Blog-Post + +## 6. Textstruktur + +- Jeder Abschnitt startet mit einer klaren H2/H3 +- `LeadParagraph` nur am Anfang (1–2 Stück) +- Normaler Text in `Paragraph`-Komponenten +- `Marker` sparsam einsetzen — max 2–3 pro Abschnitt +- `IconList` für Aufzählungen mit Pro/Contra diff --git a/apps/web/docs/KEYWORDS.md b/apps/web/docs/KEYWORDS.md new file mode 100644 index 0000000..bae8929 --- /dev/null +++ b/apps/web/docs/KEYWORDS.md @@ -0,0 +1,63 @@ +# High-Converting Keywords (Digital Architect / B2B) + +Diese 50 Keywords sind strategisch ausgewählt, um entscheidungsfreudige B2B-Kunden (Geschäftsführer, CMOs, CTOs) anzuziehen, die nach Premium-Lösungen, Performance-Architekturen und messbarem ROI suchen. Sie meiden den "Billig-Sektor" (wie "Wordpress Website günstig") und fokussieren sich auf High-End Tech und Business-Impact. + +## Kategorie 1: Enterprise Performance & Core Web Vitals (Pain Point: Sichtbarkeit & Speed) +1. "Core Web Vitals Optimierung Agentur" +2. "PageSpeed Insights 100 erreichen B2B" +3. "Website Ladezeit verbessern Conversion Rate" +4. "Mobile First Indexing Strategie 2026" +5. "Headless Commerce Performance Optimierung" +6. "Time to First Byte reduzieren Architektur" +7. "Largest Contentful Paint optimieren Next.js" +8. "Website Performance Audit B2B" +9. "Static Site Generation vs Server Side Rendering SEO" +10. "Enterprise SEO Performance Tech Stack" + +## Kategorie 2: Modern Tech Stack & Headless (Pain Point: Skalierbarkeit & Legacy-Code) +11. "Next.js Agentur Deutschland" +12. "Headless CMS Migration B2B" +13. "Vercel Hosting Enterprise Architektur" +14. "Directus CMS Agentur Setup" +15. "React Server Components Vorteile B2B" +16. "Decoupled Architecture E-Commerce" +17. "Legacy CMS ablösen Strategie" +18. "Wordpress headless machen Vor- und Nachteile" +19. "Jamstack Entwicklung Enterprise" +20. "Microservices Web Architektur" + +## Kategorie 3: B2B Conversion & Digital ROI (Pain Point: Umsatz & Leads) +21. "B2B Website Relaunch Strategie" +22. "Digital Architect Consulting B2B" +23. "Conversion Rate Optimierung Tech Stack" +24. "Lead Generierung B2B Website Architektur" +25. "High-End Website Entwicklung Kosten" +26. "ROI von Website Performance" +27. "B2B Landingpage Architektur" +28. "Website als Vertriebsmitarbeiter B2B" +29. "Data Driven Design B2B" +30. "UX/UI Architektur für hohe Conversion" + +## Kategorie 4: Infrastruktur, Sicherheit & Skalierbarkeit (Pain Point: Ausfälle & Security) +31. "Cloudflare Enterprise Setup Agentur" +32. "DDoS Schutz Web Architektur B2B" +33. "Serverless Architecture Vorteile" +34. "Hochverfügbare Website Architektur" +35. "Edge Computing für Websiten" +36. "Web Security Audit Enterprise" +37. "Zero Trust Web Architektur" +38. "Traefik Proxy Setup Next.js" +39. "Dockerized CMS Deployment" +40. "CI/CD Pipeline Webentwicklung B2B" + +## Kategorie 5: Spezifische Lösungen & "Digital Architect" Keywords (Nischen-Autorität) +41. "Digital Architect Agentur Deutschland" +42. "Mittelstand Digitalisierung Web-Infrastruktur" +43. "Industrie 4.0 B2B Website" +44. "Premium Webentwicklung Geschäftsführer" +45. "Software Architektur Beratung B2B" +46. "Web Vitals als Rankingfaktor Strategie" +47. "Next.js Directus Integration" +48. "High Performance Corporate Website" +49. "Tech Stack Audit für Mittelstand" +50. "Sustainable Web Design Architektur" diff --git a/apps/web/docs/STYLEGUIDE.md b/apps/web/docs/STYLEGUIDE.md index aea308e..c1c7bc8 100644 --- a/apps/web/docs/STYLEGUIDE.md +++ b/apps/web/docs/STYLEGUIDE.md @@ -72,3 +72,14 @@ Standardized containers ensure consistency across different screen sizes. - **Background Grid**: A subtle, low-opacity grid pattern provides a technical "blueprint" feel to the pages. - **Micro-interactions**: Hovering over icons or tags should trigger subtle scales (`105%-110%`) and color shifts. +## 7. Thumbnail & Illustration Style + +Blog post thumbnails are generated via AI image generation. They should follow these style rules: + +- **Style**: Technical blueprint / architectural illustration — clean lines, monochrome base with one highlighter accent color +- **No photos**: Abstract, geometric, or diagrammatic illustrations only +- **Color palette**: Slate grays + one accent from the highlighter palette (yellow, pink, or green) +- **Feel**: "Engineering notebook sketch" — precise, minimal, professional +- **No text in images**: Titles and labels are handled by the website layout +- **Aspect ratio**: 16:9 for blog headers, 1:1 for social media cards +- **No AI-generated photos of people, products, or realistic scenes** — only abstract/technical visualizations diff --git a/apps/web/docs/TONE.md b/apps/web/docs/TONE.md index 7d10163..1442dcc 100644 --- a/apps/web/docs/TONE.md +++ b/apps/web/docs/TONE.md @@ -39,4 +39,15 @@ Es gibt keine versteckten Prioritäten, Sonderregeln oder impliziten Erwartungsh 7. Langfristige Perspektive Die Kommunikation ist auf nachhaltige Zusammenarbeit ausgelegt, nicht auf kurzfristige Zustimmung. -Entscheidungen und Empfehlungen orientieren sich am langfristigen Nutzen des Kunden. \ No newline at end of file +Entscheidungen und Empfehlungen orientieren sich am langfristigen Nutzen des Kunden. + +8. Persona: Marc Mintel + +- Mitte 30, deutsch, bodenständig +- Umgangssprachlich aber professionell — wie ein Gespräch unter Kollegen, nicht wie ein Vortrag +- Technisch kompetent, erklärt aber auf Augenhöhe statt zu dozieren +- Der hilfreiche technische Nachbar, nicht der Silicon-Valley-Guru +- Keine Arroganz, kein Belehren — Wissen teilen statt damit angeben. Wir machen unsere Dinge auch nicht größer als Sie sind. Wir sind bescheiden. +- Spricht Probleme direkt an, ohne dramatisch zu werden +- Nutzt "ich" statt "wir" oder Passivkonstruktionen +- Vermeidet englische Buzzwords wo deutsche Begriffe existieren: "Kunden verlieren" statt "Churn Rate", "Ladezeit" statt "Time to Interactive" \ No newline at end of file diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts.bak similarity index 100% rename from apps/web/instrumentation.ts rename to apps/web/instrumentation.ts.bak diff --git a/apps/web/optimized_output.md b/apps/web/optimized_output.md new file mode 100644 index 0000000..393edb1 --- /dev/null +++ b/apps/web/optimized_output.md @@ -0,0 +1,408 @@ +📄 Reading: /Users/marcmintel/Projects/mintel.me/apps/web/content/blog/why-pagespeed-fails.mdx + +✍️ Per-section AI refinement... + Refining section 1/8... ✓ + Refining section 2/8... ✓ + Refining section 3/8... ✓ + Refining section 4/8... ✓ + Refining section 5/8... ✓ + Refining section 6/8... ✓ + Refining section 7/8... ✓ + Refining section 8/8... ✓ + → 8 sections processed + +🚀 Running content engine optimization... +🚀 Optimizing existing content (additive mode)... +📖 Loaded 42158 chars of docs context +📋 Content has 40 sections +🔍 Identifying research topics... +📚 Researching: Correlation between website loading speed (Core Web Vitals) and user bounce rate/conversion rates., The financial impact of slow websites on businesses, specifically in terms of marketing investment ROI and customer acquisition costs., Quantitative evidence supporting the claim that a 0.1-second improvement in load time leads to an 8.4% conversion increase, as cited from Deloitte Digital. +🔎 Researching: Correlation between website loading speed (Core Web Vitals) and user bounce rate/conversion rates. +📋 Research Plan: { + trendsKeywords: [ + 'Core Web Vitals', + 'Largest Contentful Paint', + 'Bounce rate', + 'Conversion rate optimization', + 'Website speed' + ], + dcVariables: [] +} +🔎 Researching: The financial impact of slow websites on businesses, specifically in terms of marketing investment ROI and customer acquisition costs. +📋 Research Plan: { + trendsKeywords: [ + 'website speed ROI', + 'page load time vs conversion', + 'core web vitals business impact', + 'customer acquisition cost ecommerce', + 'marketing spend waste site speed' + ], + dcVariables: [] +} +🔎 Researching: Quantitative evidence supporting the claim that a 0.1-second improvement in load time leads to an 8.4% conversion increase, as cited from Deloitte Digital. +📋 Research Plan: { + trendsKeywords: [ + 'Deloitte Digital page speed', + 'Milliseconds Make Money', + 'load time conversion rate', + 'e-commerce performance impact', + 'Core Web Vitals conversion' + ], + dcVariables: [] +} +📝 Planning fact insertions for 18 facts... + → 5 fact enrichments planned +🧩 Planning component additions... + → 3 component additions planned + +🔧 Applying 8 insertions to original content... + +✅ Content Engine Complete! + 📚 18 facts researched + 📊 0 diagrams generated + +🧹 Sanitizing MDX... + +📋 Generating table of contents... + → 7 sections found + → TOC already present, skipping insertion + +════════════════════════════════════════════════════════════════════════════════ +📝 OPTIMIZED CONTENT (dry-run): +════════════════════════════════════════════════════════════════════════════════ + +--- +title: "Warum Ihre Website bei Google PageSpeed scheitert" +description: "Millisekunden entscheiden über Ihren Umsatz: So optimieren Sie Ihre Web-Performance für maximale Conversion." +date: "2026-02-15" +tags: ["performance", "seo"] +--- + + + Unternehmen verbrennen Millionen für visuellen Glanz, während ihr + technisches Fundament bröckelt wie ein Altbau ohne Wartung. + + +
+

Inhaltsverzeichnis

+ +
+ + + Scheitert Ihre Website bei Google PageSpeed, verlieren Sie{" "} + 53% Ihrer Besucher in unter drei Sekunden – lange bevor sie + Ihr Angebot überhaupt sehen. Laut Google's Core Web Vitals und aktuellen Benchmarks entscheidet sich in diesen ersten Momenten, ob Ihr Unternehmen als professionell oder dilettantisch wahrgenommen wird. + + + + Als Digital Architect betrachte ich Performance nicht als Feature – sie ist + das architektonische Fundament, auf dem digitale Exzellenz erst entsteht. + + +

Der unsichtbare Umsatz-Verschleiß

+ + Stellen Sie sich ein Luxusgeschäft in Bestlage vor – perfekte Auslage, aber die Eingangstür klemmt. Kunden müssen 10 Sekunden stemmen, um einzutreten. Genau das passiert auf tausenden Websites: Nur dass dort Millisekunden über Millionenumsätze entscheiden. + + + + + + Google bewertet Websites heute primär nach Core Web Vitals – präzise Messgrößen für die Frustrationstoleranz Ihrer Nutzer. Wer hier versagt, wird vom Algorithmus unsichtbar gemacht. Seit 2021 ist Page Experience ein direkter Ranking-Faktor – schlechte Performance kostet Sie doppelt: erst Traffic, dann Conversions. + + + + + + + + Diese Zahlen sind nicht theoretisch – sie stammen direkt aus Googles Datenanalyse von über 900.000 mobilen Werbekampagnen. Jede zusätzliche Sekunde Ladezeit ab dem kritischen 5-Sekunden-Fenster kostet Sie 4,42% Ihrer Conversions – bei einem Onlineshop mit 100.000€ Monatsumsatz sind das 4.420€ verbranntes Geld. Pro Monat. Pro Sekunde Verzögerung. + + + + In meinen 15 Jahren als Software Architect habe ich gesehen, wie Unternehmen Millionen in Marketing investieren, nur um diese Besucher durch schlechte Performance sofort wieder zu verlieren. Die Mathematik ist brutal: Von 1 auf 5 Sekunden Ladezeit steigt die Bounce-Rate von 32% auf 90% – neun von zehn potenziellen Kunden verschwinden, bevor sie Ihr Angebot überhaupt sehen. + + +
+ +
+ +

Warum klassische Lösungen scheitern

+ + Die Ursache liegt in der Architektur: "All-in-One"-Systeme wie WordPress laden durchschnittlich 2,3 MB JavaScript – bevor überhaupt Ihr Content erscheint. In einer mobilen Welt mit instabilen 4G-Verbindungen (Ø 10-30 Mbit/s in Deutschland) ist das ein{" "} + architektonisches Todesurteil. Jede Anfrage durchläuft denselben aufgeblähten Render-Zyklus. + + +
+ +
+ + + Dieses Problem lässt sich nicht durch stärkere Hardware lösen. Der Flaschenhals ist die Architektur selbst: Während traditionelle Systeme bei jedem Aufruf die Datenbank befragen und komplexe Render-Zyklen durchlaufen, setze ich auf radikale Vereinfachung. + + +
+ +{`graph LR + A["Browser"] --> B["Server"] + B --> C["DB Queries"] + C --> D["Template Engine"] + D --> E["HTML Rendering"] + E --> F["3-5 Sek"] + style F fill:#fca5a5,stroke:#dc2626`} + +
+ +
+ + +
+ + + Die messbaren Folgen dieser architektonischen Versäumnisse: + + + + + + Was die Zahlen nicht zeigen: Die durchschnittliche WordPress-Site lädt 516 KB JavaScript – komplexe "All-in-One" Themes erreichen oft über 1,83 MB. Ich baue Systeme, die mit 90% weniger JavaScript auskommen und dabei bessere Interaction to Next Paint (INP) Werte liefern. HTTP Archive + + + + Die Mathematik der Geschwindigkeit ist brutal präzise: Websites, die Core Web Vitals erfüllen, haben 24% weniger Page Abandons – das bedeutet faktisch ein Viertel weniger verschwendete Werbeausgaben. Chromiums Datenauswertung zeigt: Wer die Google-Schwellenwerte unterschreitet, verliert jeden vierten Besucher noch vor dem ersten Inhalt. + + + + Der kategoriale Unterschied: Statische Architekturen liefern vorbereitete HTML-Dateien direkt vom CDN – keine Datenbankabfragen, kein Server-Rendering, keine Wartezeit. + + +

Meine Architektur der Geschwindigkeit

+ + + Statt bei jeder Anfrage Seiten dynamisch zusammenzubauen, liefere ich vorbereitete digitale Artefakte. Mein Static-First Framework garantiert Server-Antwortzeiten unter 50ms — unabhängig von der Last. Ob 10 oder 10.000 gleichzeitige Besucher: Die Performance bleibt konstant. Das ist Skalierbarkeit durch Design, nicht durch teure Hardware. + + +
+ +{`graph LR + A["Browser"] --> B["CDN Edge"] + B --> C["Statisches HTML"] + C --> D["Sofort sichtbar"] + style D fill:#86efac,stroke:#16a34a`} + +
+ + + + Anstatt Ihre Nutzer um die halbe Welt zu schicken, platziere ich Ihre Website auf einem globalen Edge-Netzwerk. Daten werden in Millisekunden aus dem Rechenzentrum geliefert, das physisch am nächsten liegt. + + + Das Ergebnis: Minimale Latenz und instant-Feeling – egal ob in Berlin, New York oder Tokio. Laut Cloudflare Performance-Studien reduziert Edge-Delivery die Latenz um durchschnittlich 60% gegenüber zentralen Servern. + + + + + Google's TTFB-Empfehlungen setzen die Grenze für exzellente Time-to-First-Byte bei 800ms. Ich unterbiete das um den Faktor 16. + + +

Die drei Säulen meiner Umsetzung

+ + + Zero-Computation am Edge: Static Site Generation platziert vorgerenderte Seiten auf globalen CDNs. Der Browser erhält HTML statt JavaScript-Berge – keine Rechenzeit, kein Parsing, keine Wartezeit. + + + Präzises Asset-Engineering: Tree-Shaking eliminiert toten Code. Ihr Kunde lädt 70% weniger JavaScript als bei klassischen Frameworks. + + + Next-Gen Media-Handling: Automatische AVIF/WebP-Auslieferung. 60% kleinere Bilder bei identischer Qualität (Netflix Tech Blog). + + + +
+ +
+ + + Die Daten zeigen unmissverständlich: Standard-CMS starten das Rennen mit bleiernen Gewichten an den Füßen. Meine Architektur eliminiert diese künstlichen Hürden an der Wurzel. Die folgende Sequenz zeigt den psychologischen Prozess während der Ladezeit: + + + + +
+ +sequenceDiagram + participant U as User + participant B as Browser + participant S as Server + participant DB as Database + + U->>B: Klick auf Anzeige + Note over U: Erwartung: <1s + B->>S: Request + S->>DB: Query (500ms) + DB->>S: Response + S->>B: HTML (1200ms) + Note over B: JavaScript laden... + B->>U: Erste Inhalte (2800ms) + Note over U: 53% bereits weg + +
+ + + Dieser Moment ist die unsichtbare Schlacht um Umsatz. Unternehmen ignorieren diese Realität und installieren stattdessen noch mehr Plugins – eine Abwärtsspirale aus Komplexität und Frustration. + + +
+ +
+ + + + + Der hidden ROI-Killer: Eine 0,1-Sekunden-Verbesserung der mobilen Ladezeit steigert den durchschnittlichen Warenwert um 9,2% – bei identischem Traffic und Marketing-Budget. Deloittes Retail-Analyse beweist: Performance-Optimierung ist nicht Kostenfaktor, sondern der effizienteste Growth-Hebel den Sie haben. + + + + Zahlen lügen nicht. Wer Performance vernachlässigt, sabotiert aktiv sein eigenes Wachstum. Ich baue keine Websites – ich konstruiere ökonomische Hebel. + + + + JavaScript ist die teuerste Ressource einer Website: Es muss heruntergeladen, dekomprimiert, geparst und ausgeführt werden. 1 MB JavaScript kostet auf mobilen Geräten 2-5 Sekunden mehr Verarbeitungszeit als 1 MB Bilder (V8 Team). Deshalb setze ich auf Architekturen, die JavaScript nur dort laden, wo es wirklich gebraucht wird – nicht als Standard-Ballast. + + +

Der wirtschaftliche Case

+ + Baukästen wirken günstiger – bis Sie die Opportunitätskosten berechnen. Bei 5.000 € monatlichem Marketing-Budget und 30 % Conversion-Verlust durch Performance-Probleme verbrennen Sie 18.000 € jährlich. Messbar. Vermeidbar. + + + + Die Zahlen sind brutal eindeutig: Rakuten 24 steigerte den Revenue per Visitor um 53 % nach Core Web Vitals-Optimierung. Walmart verdoppelte die Conversion-Rate durch Reduktion der Ladezeit von 2 auf 1 Sekunde. Jede 100ms kostet Sie Kunden. + + + + + + Was diese Cases verschweigen: Performance-Verbesserungen wirken exponentiell auf Ihren Marketing-ROI. Eine 0,1-Sekunden-Optimierung führt zu 1,3% mehr Engagement bei Lead-Gen-Sites und 2,1% besserer Conversion-Progression im Travel-Sektor. Googles MILO-Studie dokumentiert: Speed ist der unsichtbare Conversion-Multiplikator. + + + + Ein Detail, das Agenturen gerne verschweigen: 75% des typischen Website-JavaScript wird beim ersten Laden gar nicht verwendet. WordPress-Plugins laden ganze Bibliotheken für einzelne Features. Ich baue so, dass nur das geladen wird, was Ihre Besucher tatsächlich brauchen – HTTP Archive zeigt: durchschnittlich 500KB ungenutztes JavaScript pro Seite. + + + + Der ROI maßgeschneiderter Architekturen liegt nicht in den Entwicklungskosten – sondern in der Eliminierung struktureller Performance-Barrieren, die Ihre Conversion-Rate täglich sabotieren. Während Sie schlafen. + + +

Wann meine Architektur für Sie Sinn macht

+ + Ich bin Partner für Unternehmen, deren Website geschäftskritischer Umsatztreiber ist – nicht Visitenkarte. Wenn jede Sekunde verzögerten Seitenaufbaus direkt Conversions kostet, wird technische Exzellenz zur Grundvoraussetzung, nicht zum Nice-to-have. + + + +Der ROI liegt nicht in gesparten Entwicklungskosten, sondern in eliminierten Opportunitätskosten durch verlorene Conversions. + + + + Diese Erkenntnis markiert den Wendepunkt. Wer Performance als bloße technische Kennzahl betrachtet, übersieht den wirtschaftlichen Hebel – und lässt Umsatzpotenzial auf dem Tisch liegen. + + + + Die Datenlage ist eindeutig: Sites mit 5-Sekunden-Ladezeit haben 70% längere Sitzungsdauern als solche mit 19 Sekunden. Think with Google Das bedeutet nicht nur mehr Conversions, sondern auch bessere Nutzersignale – ein sich selbst verstärkender Kreislauf, der Ihre organische Sichtbarkeit exponentiell steigert. + + + + Seit Googles "Page Experience" Update (2021) sind Core Web Vitals harte Ranking-Faktoren. Websites, die alle Schwellenwerte erfüllen, haben eine 24% geringere Abbruchrate{" "} + + laut Google-Analyse + + {" "}— Performance ist SEO, nicht umgekehrt. Wer hier nachlässig ist, kämpft mit angezogener Handbremse. + + +
+ +
+ + + Der Teufelskreis wird perfekt: Langsame Sites verlieren organische Rankings durch schlechte Core Web Vitals, was höhere Paid-Search-Kosten zur Kompensation verlorener SEO-Sichtbarkeit erzwingt. Google Search Central macht es unmissverständlich klar: Performance ist nicht mehr optional – es ist Ihre digitale Überlebensstrategie. + + +
+ +
+ +

Fazit: Respekt vor der Zeit Ihrer Nutzer

+ + Geschwindigkeit ist kein Feature – sie ist Ausdruck von Respekt. Jede eingesparte Sekunde Ladezeit signalisiert: "Deine Zeit ist wertvoll." Diese Haltung unterscheidet Premium-Erlebnisse von digitaler Beliebigkeit. + + + Ich verwandle Websites in präzise Wachstums-Maschinen: messbar schnell, skalierbar stabil. Performance-Exzellenz zahlt sich mehrfach aus – in Ladezeiten unter 2 Sekunden, Conversion-Steigerungen von 15-30% und nachweisbarem ROI durch Core Web Vitals, der sich in harten Kennzahlen niederschlägt. + + +════════════════════════════════════════════════════════════════════════════════ + +📚 Research: + 1. As page load time goes from one second to three seconds, the probability of bounce increases 32%. [Google Developers] + 2. As page load time goes from one second to five seconds, the probability of bounce increases 90%. [Google Developers] + 3. A speed improvement of 0.1 seconds in mobile site speed can result in an 8.4% increase in conversion rates for retail sites and a 10.1% increase for travel sites. [Deloitte Digital] + 4. Websites that meet the Core Web Vitals thresholds are 24% less likely to have users abandon page loads. [Chromium Blog] + 5. Average conversion rates are 3x higher for eCommerce sites that load in 1 second compared to sites that load in 5 seconds. [Portent] + 6. The first five seconds of page-load time have the highest impact on conversion rates; after five seconds, the conversion rate drops by an average of 4.42% for each additional second of load time. [Portent] + 7. A one-second improvement in mobile load times can increase conversion rates by up to 27%, directly impacting the ROI of paid marketing traffic. [Google Developers / Akamai] + 8. Deloitte reported that a 0.1s improvement in mobile site speed led to a 9.1% increase in conversion rates for retail sites and a 10% increase for travel sites. [Deloitte Digital] + 9. Approximately 53% of mobile site visits are abandoned if pages take longer than 3 seconds to load, significantly increasing the effective Customer Acquisition Cost (CAC). [Google Data] + 10. The probability of bounce increases by 32% as page load time goes from 1 second to 3 seconds, and by 123% when load time increases to 10 seconds. [Google Think with Google] + 11. Slow site speed impacts organic visibility because Core Web Vitals (LCP, FID, CLS) are a direct ranking factor in Google's search algorithm, causing higher costs for paid search to compensate for lost organic traffic. [Google Search Central] + 12. A site that loads in 1 second has a conversion rate 3x higher than a site that loads in 5 seconds, effectively tripling the efficiency of marketing spend. [Portent] + 13. A speed improvement of 0.1 seconds was found to increase conversion rates by 8.4% for retail sites and 10.1% for travel sites. [Deloitte Digital] + 14. The same 0.1s decrease in mobile site load time resulted in an increase of average order value by 9.2% for retail consumers. [Deloitte Digital] + 15. In the luxury retail segment, a 0.1s improvement in speed correlated with an 8% increase in page views per session. [Google Developers] + 16. Reducing latency by 0.1s led to an 1.3% increase in customer engagement (page views) for lead generation websites. [Deloitte Digital] + 17. In the travel sector, a 0.1s improvement in load time led to a 2.1% increase in the progression of users from the search page to the product details page. [Google / MILO Study] + 18. For retail sites, the 0.1s speed boost was associated with a 5.2% decrease in bounce rate on product listing pages. [Deloitte / 55 Study] diff --git a/apps/web/package.json b/apps/web/package.json index 6a2ad55..702d8dd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,7 +4,7 @@ "version": "0.1.0", "description": "Technical problem solver's blog - practical insights and learning notes", "scripts": { - "dev": "next dev", + "dev": "rm -rf .next .contentlayer/.cache && concurrently -k -p \"[{name}]\" -n \"content,next\" -c \"magenta,cyan\" \"contentlayer2 dev\" \"next dev\"", "build": "next build --webpack", "start": "next start", "lint": "eslint app src scripts video", @@ -37,7 +37,9 @@ "@mintel/content-engine": "link:../../../at-mintel/packages/content-engine", "@mintel/meme-generator": "link:../../../at-mintel/packages/meme-generator", "@mintel/pdf": "^1.8.0", + "@mintel/thumbnail-generator": "link:../../../at-mintel/packages/thumbnail-generator", "@next/mdx": "^16.1.6", + "@next/third-parties": "^16.1.6", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.1.0", "@opentelemetry/core": "^2.1.0", @@ -62,6 +64,7 @@ "crawlee": "^3.15.3", "esbuild": "^0.27.3", "framer-motion": "^12.29.2", + "html-to-image": "^1.11.13", "ioredis": "^5.9.1", "lucide-react": "^0.468.0", "mermaid": "^11.12.2", @@ -75,6 +78,9 @@ "qrcode": "^1.5.4", "react": "^19.2.3", "react-dom": "^19.2.3", + "react-social-media-embed": "^2.5.18", + "react-tweet": "^3.3.0", + "recharts": "^3.7.0", "remotion": "^4.0.414", "shiki": "^1.24.2", "tailwind-merge": "^3.4.0", @@ -102,6 +108,7 @@ "@types/qrcode": "^1.5.6", "autoprefixer": "^10.4.20", "cheerio": "^1.1.2", + "concurrently": "^9.2.1", "eslint": "10.0.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/apps/web/public/blog/der-google-pagespeed-guide-warum-ladezeit-ihr-wichtigster-umsatzhebel-ist.png b/apps/web/public/blog/der-google-pagespeed-guide-warum-ladezeit-ihr-wichtigster-umsatzhebel-ist.png new file mode 100644 index 0000000..541745d Binary files /dev/null and b/apps/web/public/blog/der-google-pagespeed-guide-warum-ladezeit-ihr-wichtigster-umsatzhebel-ist.png differ diff --git a/apps/web/public_test.html b/apps/web/public_test.html new file mode 100644 index 0000000..6835076 --- /dev/null +++ b/apps/web/public_test.html @@ -0,0 +1,35 @@ +Warum deine Website bei Google PageSpeed scheitert (und wie du es fixst) | Marc Mintel
Zurück zur Übersicht

Warum deine Website bei Google PageSpeed scheitert (und wie du es fixst)

Hör auf, WordPress-Plugins zu installieren. Die Wahrheit über Ladezeiten, verlorene Kunden und warum dein 3000€-Design nutzlos ist, wenn keiner wartet.

2 min Lesezeit
WHY--076F
#Performance#Business#SEO#Next.js

Hand aufs Herz: Du hast viel Geld für deine Website bezahlt. Das Design ist wunderschön, die Marketing-Texte sitzen, die Bilder zeigen dein Team im besten Licht und das Firmenlogo glänzt. Aber wenn ein potenzieller Kunde auf dem Smartphone bei Google auf deinen Link klickt, passiert... erst mal nichts.

+

Der Bildschirm bleibt weiß. 1 Sekunde vergeht. 2 Sekunden verstreichen. Bei 3 Sekunden fängt der Daumen an zu zucken. Bei 4 Sekunden ist der Kunde weg. Zurück zu Google. Hin zur Konkurrenz, deren Seite sofort geladen hat.

+

Das ist keine Theorie. Das ist die stille, brutale Realität für tausende Kleinunternehmen und Mittelständler (KMUs). Du selbst denkst vielleicht, an deinem Laptop im Büro am Glasfaser-Anschluss ist deine Seite "schnell genug". Aber Google sieht das anders. Und deine Endkunden im mobilen Netz erst recht.

+

Inhaltsverzeichnis

+

Die brutale Mathematik: Was kostet dich das Warten?

+

Viele klassische Web-Agenturen verkaufen "Performance" oft als nettes Extra. "Das machen wir später mal update sicher, nach dem Go-Live". Bullshit. Lass dir das nicht einreden. Performance ist kein isoliertes "Geek-Feature". Performance ist das absolute Fundament für jeden einzelnen Euro Umsatz oder Geschäftskontakt, den du digital machst.

+

Schau dir diese Simulation an. Gib deine eigenen, realen Zahlen deines Geschäfts ein. Sei ehrlich zu dir selbst.

+
Performance / Revenue Simulator
Active
Monatlicher TrafficVisits
5,000
Ø KundenwertEUR
150
Website LadezeitSekunden
4.0s
Entgangener Umsatz (Mtl.)
1,725EUR
Verlorene Leads (Mtl.)
12Nutzer

Ref: Google/Akamai
(0.07 Drop Rate pro Sek.)

+
Wir brauchen ein schickes Design / Altes WordPress-Setup behalten / Ladezeit von 4 Sekunden ignorieren / Sich wundern warum keine Anfragen kommen
+

Merkst du was? Wir reden hier nicht von abstrakten "Millisekunden für Megakonzerne" wie Amazon oder Google. Wir reden von deinen lokalen Kunden. Ein Handwerksbetrieb, dem 5 kaufbereite Anfragen im Monat fehlen, weil sie beim Laden abbrechen, verliert zigtausende Euro Jahresumsatz. Ein B2B-Dienstleister, der 1.000 Klicks via LinkedIn einkauft, verbrennt fast die Hälfte seines Budgets ins absolute Nichts, wenn die Landingpage nicht sofort da ist.

+

Die "Todeszone" der Ladezeit

+

Es gibt einen kritischen Schwellenwert in der digitalen Wahrnehmung, in dem leider die meisten standardmäßigen WordPress- und Homepage-Baukasten-Seiten festhängen. Ich nenne diesen Bereich branchenintern die rote Todeszone. Alles über 2.5 Sekunden Ladezeit fängt an, massiv messbar geschäftsschädigend zu wirken.

+
Conversion Curve
Kritische Schwelle >2.5s
100%
1s
93%
2s
82%
3s
65%
4s
45%
5s
30%
6s
20%
7s
12%
8s
+

Wenn deine Seite in der rechten Hälfte dieses Diagramms liegt, subventionierst du effektiv deine Konkurrenz. Du schaltest vielleicht teure Google Ads? Du oder dein Team verbringen Stunden mit starken LinkedIn Posts? Du verteilst aufwendig gedruckte Flyer? Wenn die Zielseite, auf die all diese Maßnahmen verweisen, zu langsam ist, ist das gesamte eingesetzte Marketing-Kapital rausgeworfen.

+

Warum "Optimierungs-Plugins" nicht funktionieren

+

Das ist der absolute Klassiker in der Branche. Dein Google PageSpeed Insights Test ist tiefrot. Du (oder dein Neffe, der "irgendwas mit Medien" macht) googelst nach einer Lösung und installierst blind Plugins wie WP Rocket, Autoptimize oder Smush.

+

Das Ergebnis nach Stunden des Herumbastelns?

+
Performance-Probleme an der Wurzel packen / Noch ein Cache-Plugin installieren
+

Plugins kleben nur ein dünnes Pflaster auf eine tiefe Wunde in der Software-Architektur. Das Kernproblem deines Setups liegt meistens viel tiefer verankert:

+
    +
  1. Billiges Shared-Hosting: Ein 5€-virtueller-Server teilt sich Ressourcen mit 500 anderen Webseiten. Er kann unter Last schlicht keine Wunder vollbringen und antwortet träge.
  2. +
  3. Aufgeblähte Multi-Purpose Themes: Dein gekauftes "Premium-Theme" für 60$ lädt dutzende Schriftarten, ungenutzte Slider-Skripte und Tracker, die du für dein Geschäftsmodell in 100 Jahren niemals brauchen wirst.
  4. +
  5. Veraltete Render-Architektur: Dass PHP und eine langsame MySQL-Datenbank bei jedem einzelnen Seitenaufruf die Webseite von Grund auf neu berechnen müssen, ist Technologie-Stand von vor über zehn Jahren (oft 2010er Jahre).
  6. +
+

Die Lösung ist Architektur, nicht Pflaster

Moderne, industriell gefertigte B2B-Websites (wie die Infrastruktur, die mein Team baut) funktionieren technologisch fundamental anders. Sie werden komplett statisch und im Vorfeld hochoptimiert generiert.

Das bedeutet in der Praxis: Der Server baut die komplette Seite schon dann fertig, wenn ich sie per Code ausliefere – und nicht erst in dem Moment in dem der Kunde auf dem Handy den Link aufruft.

Klassisches CMS (WordPress/Typo3)
  • Server muss bei Klick HTML aufwendig berechnen
  • Datenbank-Abfragen bremsen jeden Seitenaufruf
  • Hohe Anfälligkeit für Sicherheitslücken & Updates
  • Ladezeit schwankt extrem (abhängig von Host-Auslastung)
Next.js / Modern JAM-Stack
  • Webseite liegt als pre-compiled HTML 24/7 abrufbereit
  • Auslieferung via dezentralem CDN (z.B. Frankfurt Node)
  • Keine direkte Datenbank-Anbindung pro Seitenklick
  • Konsistent im grünen 95+ PageSpeed Bereich
+

Wie funktioniert das? (Hinter den Kulissen für Techniker)

+

Ich setze in geschäftskritischen Umgebungen nicht auf WordPress. Ich baue auf Enterprise-Stacks (wie React Server Components, Next.js und globale Edge Networks), die ohne Kompromisse für maximale Geschwindigkeit und Stabilität entworfen wurden.

+ +

Hinweis zum Diagramm: Links siehst du den direkten Pfad eines modernen Edge-Setups. Keine Datenbank, kein Rechenprozess. Rechts der klassische Flaschenhals, den 90% des Internets nutzen.

+

Dein nächster Schritt

+

Du hast den ROI-Rechner oben benutzt. Du hast die ernüchternden Zahlen schwarz auf weiß gesehen. Du weißt jetzt, dass das Konzept von "schnell genug" für moderne Nutzererwartungen eine gefährliche Illusion ist. Die Aufmerksamkeitsspanne deiner Zielgruppe verzeiht keine 4 Sekunden Ladezeit.

+

Es gibt keine Ausreden mehr. Hör auf, jeden Tag unbemerkt deinen hart erarbeiteten Umsatz zu verschenken.

+

Lass uns deine Seite nicht einfach nur per Google-Tool messen. Wir schauen uns das nicht nur als simplen "Score" an, sondern betrachten deine Performance als knallharten Business-Faktor mit direktem Einfluss auf deinen Umsatz und deine Unternehmens-Sichtbarkeit.

+

Wenn du bereit bist, die digitale Infrastruktur deines Unternehmens so professionell aufzustellen, wie dein echtes Produkt bereits ist, dann ist ein statischer Modern-Stack der einzig logische Schritt nach vorn.

+
\ No newline at end of file diff --git a/apps/web/scripts/ai-estimate.ts b/apps/web/scripts/ai-estimate.ts index 3e31d93..af54f81 100644 --- a/apps/web/scripts/ai-estimate.ts +++ b/apps/web/scripts/ai-estimate.ts @@ -208,7 +208,7 @@ async function main() { if (usage.prompt > 0) { console.log("--------------------------------------------------"); console.log("📊 ACCUMULATED API USAGE (SUM OF 6 PASSES)"); - console.log(` Model: google/gemini-3-flash-preview`); + console.log(` Model: google/gemini-2.5-flash`); console.log(` Total Prompt: ${usage.prompt.toLocaleString()}`); console.log(` Total Completion: ${usage.completion.toLocaleString()}`); console.log( @@ -244,7 +244,7 @@ Return ONLY the bullet points. No intro/outro. const resp = await axios.post( "https://openrouter.ai/api/v1/chat/completions", { - model: "google/gemini-3-flash-preview", + model: "google/gemini-2.5-flash", messages: [ { role: "system", content: systemPrompt }, { @@ -440,17 +440,16 @@ Focus 100% on the BRIEFING text provided by the user. Use the DISTILLED_CRAWL on - API Integrations: 800 € / stk - Inhalts-Verwaltung (CMS-Modul): 1.500 € (optional) -${ - budget - ? `### BUDGET LOGIC (ULTRA-STRICT): +${budget + ? `### BUDGET LOGIC (ULTRA-STRICT): 1. **Mental Calculation**: Start with 7.000 €. Add items based on the reference above. 2. **Hard Ceiling**: If total > ${budget}, you MUST discard lower priority items. 3. **Priority**: High-End Design and Core Pages > Features. 4. **Restriction**: For ${budget}, do NOT exceed 2 features and 4 extra pages. 5. THE TOTAL COST CALCULATED BY THESE RULES MUST BE <= ${budget}. 6. Do NOT mention the budget in any string fields.` - : "" -} + : "" + } - ** features **: Items from the FEATURE_REFERENCE. - ** ABSOLUTE CONSERVATIVE RULE **: Only use features if the briefing implies *dynamic complexity* (CMS, filtering, search, database). @@ -500,7 +499,7 @@ ${ const p1Resp = await axios.post( "https://openrouter.ai/api/v1/chat/completions", { - model: "google/gemini-3-flash-preview", + model: "google/gemini-2.5-flash", messages: [ { role: "system", content: pass1SystemPrompt }, { role: "user", content: pass1UserPrompt }, @@ -547,7 +546,7 @@ Return only the corrected 'features' and 'otherPages' arrays. const p15Resp = await axios.post( "https://openrouter.ai/api/v1/chat/completions", { - model: "google/gemini-3-flash-preview", + model: "google/gemini-2.5-flash", messages: [ { role: "system", content: pass15SystemPrompt }, { @@ -603,7 +602,7 @@ ${JSON.stringify(facts, null, 2)} const p2Resp = await axios.post( "https://openrouter.ai/api/v1/chat/completions", { - model: "google/gemini-3-flash-preview", + model: "google/gemini-2.5-flash", messages: [ { role: "system", content: pass2SystemPrompt }, { role: "user", content: briefing }, @@ -658,7 +657,7 @@ ${tone} const p3Resp = await axios.post( "https://openrouter.ai/api/v1/chat/completions", { - model: "google/gemini-3-flash-preview", + model: "google/gemini-2.5-flash", messages: [ { role: "system", content: pass3SystemPrompt }, { @@ -712,7 +711,7 @@ ${JSON.stringify({ facts, strategy }, null, 2)} const p4Resp = await axios.post( "https://openrouter.ai/api/v1/chat/completions", { - model: "google/gemini-3-flash-preview", + model: "google/gemini-2.5-flash", messages: [ { role: "system", content: pass4SystemPrompt }, { @@ -808,7 +807,7 @@ ${JSON.stringify({ facts, details, strategy, ia }, null, 2)} const p5Resp = await axios.post( "https://openrouter.ai/api/v1/chat/completions", { - model: "google/gemini-3-flash-preview", + model: "google/gemini-2.5-flash", messages: [ { role: "system", content: pass5SystemPrompt }, { role: "user", content: briefing }, @@ -862,7 +861,7 @@ ${JSON.stringify({ facts, strategy, ia, positionsData }, null, 2)} const p6Resp = await axios.post( "https://openrouter.ai/api/v1/chat/completions", { - model: "google/gemini-3-flash-preview", + model: "google/gemini-2.5-flash", messages: [ { role: "system", content: pass6SystemPrompt }, { role: "user", content: `BRIEFING_TRUTH: \n${briefing} ` }, @@ -1004,8 +1003,8 @@ ${JSON.stringify({ facts, strategy, ia, positionsData }, null, 2)} const normalizedValue = typeof value === "object" ? (value as any).beschreibung || - (value as any).description || - JSON.stringify(value) + (value as any).description || + JSON.stringify(value) : value; normalized[key] = normalizedValue as string; }); diff --git a/apps/web/scripts/clean-mermaid-format.ts b/apps/web/scripts/clean-mermaid-format.ts deleted file mode 100644 index 5171e7a..0000000 --- a/apps/web/scripts/clean-mermaid-format.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const MDX_DIR = path.join(process.cwd(), 'content/blog'); - -function cleanMermaidFormatting(content: string): string { - // Fix: The repair script reformatted tags badly with extra blank lines - // Pattern: - // Should be: - - // Remove empty lines between '); - - // Fix: Remove trailing whitespace-only lines before id= or title= or showShare= - result = result.replace(/`\}\s*\n\s*\n\s*(id=)/g, '`}\n $1'); - result = result.replace(/`\}\s*\n\s*\n\s*(title=)/g, '`}\n $1'); - result = result.replace(/`\}\s*\n\s*\n\s*(showShare=)/g, '`}\n $1'); - - return result; -} - -function processFiles() { - const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx')); - let fixCount = 0; - - for (const file of files) { - const filePath = path.join(MDX_DIR, file); - const content = fs.readFileSync(filePath, 'utf8'); - const cleaned = cleanMermaidFormatting(content); - - if (content !== cleaned) { - fs.writeFileSync(filePath, cleaned); - fixCount++; - console.log(`✅ Cleaned formatting in ${file}`); - } else { - console.log(`- ${file} OK`); - } - } - - console.log(`\nTotal cleaned: ${fixCount}`); -} - -processFiles(); diff --git a/apps/web/scripts/convert-diagrams-to-children.ts b/apps/web/scripts/convert-diagrams-to-children.ts deleted file mode 100644 index 230a25d..0000000 --- a/apps/web/scripts/convert-diagrams-to-children.ts +++ /dev/null @@ -1,365 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const MDX_DIR = path.join(process.cwd(), 'content/blog'); - -/** - * Convert to {`...`} - * This fixes the RSC serialization issue where template literal props are stripped. - */ -function convertMermaidToChildren(content: string): string { - // Match (self-closing) - // We need a multi-pass approach since the graph prop can appear anywhere in the tag - - const mermaidBlockRegex = //g; - - return content.replace(mermaidBlockRegex, (match) => { - // Extract the graph prop value (template literal) - const graphMatch = match.match(/graph=\{`([\s\S]*?)`\}/); - if (!graphMatch) return match; // No graph prop, skip - - const graphContent = graphMatch[1]; - - // Remove the graph prop from the tag - let cleanedTag = match.replace(/\s*graph=\{`[\s\S]*?`\}\s*/g, ' '); - - // Remove the self-closing /> and add children - cleanedTag = cleanedTag.replace(/\s*\/>$/, ''); - - // Clean up extra whitespace - cleanedTag = cleanedTag.replace(/\s+/g, ' ').trim(); - - return `${cleanedTag}>\n{\`${graphContent}\`}\n`; - }); -} - -/** - * Convert to {`pie\n "label": value\n...`} - */ -function convertDiagramPie(content: string): string { - const pieRegex = //g; - - return content.replace(pieRegex, (match) => { - // Extract data array - const dataMatch = match.match(/data=\{\[([\s\S]*?)\]\}/); - if (!dataMatch) return match; - - const dataStr = dataMatch[1]; - - // Parse the array items: { label: "...", value: ... } - const items: { label: string; value: number }[] = []; - const itemRegex = /\{\s*label:\s*"([^"]+)"\s*,\s*value:\s*(\d+)\s*\}/g; - let itemMatch; - while ((itemMatch = itemRegex.exec(dataStr)) !== null) { - items.push({ label: itemMatch[1], value: parseInt(itemMatch[2]) }); - } - - if (items.length === 0) return match; - - // Build pie chart Mermaid syntax - const pieLines = items.map(item => ` "${item.label}" : ${item.value}`).join('\n'); - const pieGraph = `pie\n${pieLines}`; - - // Extract other props - const titleMatch = match.match(/title="([^"]+)"/); - const captionMatch = match.match(/caption="([^"]+)"/); - const idMatch = match.match(/id="([^"]+)"/); - const showShareMatch = match.match(/showShare=\{(true|false)\}/); - - const title = titleMatch ? titleMatch[1] : ''; - const caption = captionMatch ? captionMatch[1] : ''; - const id = idMatch ? idMatch[1] : ''; - const showShare = showShareMatch ? showShareMatch[1] : 'true'; - - // Build replacement with Mermaid component wrapped in div - let result = `
\n `; - result += `\n{\`${pieGraph}\`}\n`; - if (caption) { - result += `\n
\n ${caption}\n
`; - } - result += `\n
`; - - return result; - }); -} - -/** - * Convert to {`gantt\n...`} - */ -function convertDiagramGantt(content: string): string { - const ganttRegex = //g; - - return content.replace(ganttRegex, (match) => { - // Extract tasks array - const tasksMatch = match.match(/tasks=\{\[([\s\S]*?)\]\}/); - if (!tasksMatch) return match; - - const tasksStr = tasksMatch[1]; - - // Parse the task items - const taskRegex = /\{\s*id:\s*"([^"]+)"\s*,\s*name:\s*"([^"]+)"\s*,\s*start:\s*"([^"]+)"\s*,\s*duration:\s*"([^"]+)"\s*(?:,\s*dependencies:\s*\[([^\]]*)\])?\s*\}/g; - const tasks: { id: string; name: string; start: string; duration: string; deps?: string }[] = []; - let taskMatch; - while ((taskMatch = taskRegex.exec(tasksStr)) !== null) { - tasks.push({ - id: taskMatch[1], - name: taskMatch[2], - start: taskMatch[3], - duration: taskMatch[4], - deps: taskMatch[5] || '', - }); - } - - if (tasks.length === 0) return match; - - // Build gantt chart Mermaid syntax - const ganttLines = tasks.map(task => { - const deps = task.deps ? `, after ${task.deps.replace(/"/g, '').trim()}` : ''; - return ` ${task.name} :${task.id}, ${task.start}, ${task.duration}${deps}`; - }).join('\n'); - const ganttGraph = `gantt\n dateFormat YYYY-MM-DD\n${ganttLines}`; - - // Extract other props - const titleMatch = match.match(/title="([^"]+)"/); - const captionMatch = match.match(/caption="([^"]+)"/); - const idMatch = match.match(/id="([^"]+)"/); - const showShareMatch = match.match(/showShare=\{(true|false)\}/); - - const title = titleMatch ? titleMatch[1] : ''; - const caption = captionMatch ? captionMatch[1] : ''; - const id = idMatch ? idMatch[1] : ''; - const showShare = showShareMatch ? showShareMatch[1] : 'true'; - - let result = `
\n `; - result += `\n{\`${ganttGraph}\`}\n`; - if (caption) { - result += `\n
\n ${caption}\n
`; - } - result += `\n
`; - - return result; - }); -} - -/** - * Convert to {`sequenceDiagram\n...`} - */ -function convertDiagramSequence(content: string): string { - const seqRegex = //g; - - return content.replace(seqRegex, (match) => { - // Extract participants array - const participantsMatch = match.match(/participants=\{\[([\s\S]*?)\]\}/); - if (!participantsMatch) return match; - - // Extract messages array - const messagesMatch = match.match(/messages=\{\[([\s\S]*?)\]\}/); - if (!messagesMatch) return match; - - const participantsStr = participantsMatch[1]; - const messagesStr = messagesMatch[1]; - - // Parse participants - const participants = participantsStr.match(/"([^"]+)"/g)?.map(s => s.replace(/"/g, '')) || []; - - // Parse messages - const msgRegex = /\{\s*from:\s*"([^"]+)"\s*,\s*to:\s*"([^"]+)"\s*,\s*message:\s*"([^"]+)"(?:\s*,\s*type:\s*"([^"]+)")?\s*\}/g; - const messages: { from: string; to: string; message: string; type?: string }[] = []; - let msgMatch; - while ((msgMatch = msgRegex.exec(messagesStr)) !== null) { - messages.push({ - from: msgMatch[1], - to: msgMatch[2], - message: msgMatch[3], - type: msgMatch[4], - }); - } - - if (participants.length === 0 || messages.length === 0) return match; - - // Build sequence diagram Mermaid syntax - const getArrow = (type?: string) => { - switch (type) { - case 'dotted': return '-->>'; - case 'async': return '->>'; - default: return '->>'; - } - }; - - const participantLines = participants.map(p => ` participant ${p}`).join('\n'); - const messageLines = messages.map(m => ` ${m.from}${getArrow(m.type)}${m.to}: ${m.message}`).join('\n'); - const seqGraph = `sequenceDiagram\n${participantLines}\n${messageLines}`; - - // Extract other props - const titleMatch = match.match(/title="([^"]+)"/); - const captionMatch = match.match(/caption="([^"]+)"/); - const idMatch = match.match(/id="([^"]+)"/); - const showShareMatch = match.match(/showShare=\{(true|false)\}/); - - const title = titleMatch ? titleMatch[1] : ''; - const caption = captionMatch ? captionMatch[1] : ''; - const id = idMatch ? idMatch[1] : ''; - const showShare = showShareMatch ? showShareMatch[1] : 'true'; - - let result = `
\n `; - result += `\n{\`${seqGraph}\`}\n`; - if (caption) { - result += `\n
\n ${caption}\n
`; - } - result += `\n
`; - - return result; - }); -} - -/** - * Convert to {`timeline\n...`} - */ -function convertDiagramTimeline(content: string): string { - const timelineRegex = //g; - - return content.replace(timelineRegex, (match) => { - const eventsMatch = match.match(/events=\{\[([\s\S]*?)\]\}/); - if (!eventsMatch) return match; - - const eventsStr = eventsMatch[1]; - - const eventRegex = /\{\s*year:\s*"([^"]+)"\s*,\s*title:\s*"([^"]+)"\s*\}/g; - const events: { year: string; title: string }[] = []; - let eventMatch; - while ((eventMatch = eventRegex.exec(eventsStr)) !== null) { - events.push({ year: eventMatch[1], title: eventMatch[2] }); - } - - if (events.length === 0) return match; - - const titleMatch = match.match(/title="([^"]+)"/); - const captionMatch = match.match(/caption="([^"]+)"/); - const idMatch = match.match(/id="([^"]+)"/); - const showShareMatch = match.match(/showShare=\{(true|false)\}/); - - const title = titleMatch ? titleMatch[1] : ''; - const caption = captionMatch ? captionMatch[1] : ''; - const id = idMatch ? idMatch[1] : ''; - const showShare = showShareMatch ? showShareMatch[1] : 'true'; - - const eventLines = events.map(e => ` ${e.year} : ${e.title}`).join('\n'); - const timelineGraph = `timeline\n title ${title || 'Timeline'}\n${eventLines}`; - - let result = `
\n `; - result += `\n{\`${timelineGraph}\`}\n`; - if (caption) { - result += `\n
\n ${caption}\n
`; - } - result += `\n
`; - - return result; - }); -} - -/** - * Convert to {`stateDiagram-v2\n...`} - */ -function convertDiagramState(content: string): string { - const stateRegex = //g; - - return content.replace(stateRegex, (match) => { - // Extract transitions - const transitionsMatch = match.match(/transitions=\{\[([\s\S]*?)\]\}/); - if (!transitionsMatch) return match; - - const transitionsStr = transitionsMatch[1]; - - const transRegex = /\{\s*from:\s*"([^"]+)"\s*,\s*to:\s*"([^"]+)"(?:\s*,\s*label:\s*"([^"]+)")?\s*\}/g; - const transitions: { from: string; to: string; label?: string }[] = []; - let transMatch; - while ((transMatch = transRegex.exec(transitionsStr)) !== null) { - transitions.push({ - from: transMatch[1], - to: transMatch[2], - label: transMatch[3], - }); - } - - if (transitions.length === 0) return match; - - // Extract initialState - const initialStateMatch = match.match(/initialState="([^"]+)"/); - const initialState = initialStateMatch ? initialStateMatch[1] : ''; - - // Extract finalStates - const finalStatesMatch = match.match(/finalStates=\{\[([^\]]*)\]\}/); - const finalStatesStr = finalStatesMatch ? finalStatesMatch[1] : ''; - const finalStates = finalStatesStr.match(/"([^"]+)"/g)?.map(s => s.replace(/"/g, '')) || []; - - const titleMatch = match.match(/title="([^"]+)"/); - const captionMatch = match.match(/caption="([^"]+)"/); - const idMatch = match.match(/id="([^"]+)"/); - const showShareMatch = match.match(/showShare=\{(true|false)\}/); - - const title = titleMatch ? titleMatch[1] : ''; - const caption = captionMatch ? captionMatch[1] : ''; - const id = idMatch ? idMatch[1] : ''; - const showShare = showShareMatch ? showShareMatch[1] : 'true'; - - let stateLines = 'stateDiagram-v2'; - if (initialState) { - stateLines += `\n [*] --> ${initialState}`; - } - stateLines += '\n' + transitions.map(t => { - const label = t.label ? ` : ${t.label}` : ''; - return ` ${t.from} --> ${t.to}${label}`; - }).join('\n'); - stateLines += '\n' + finalStates.map(s => ` ${s} --> [*]`).join('\n'); - - let result = `
\n `; - result += `\n{\`${stateLines}\`}\n`; - if (caption) { - result += `\n
\n ${caption}\n
`; - } - result += `\n
`; - - return result; - }); -} - -function processFiles() { - const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx')); - - for (const file of files) { - const filePath = path.join(MDX_DIR, file); - let content = fs.readFileSync(filePath, 'utf8'); - const original = content; - - content = convertMermaidToChildren(content); - content = convertDiagramPie(content); - content = convertDiagramGantt(content); - content = convertDiagramSequence(content); - content = convertDiagramTimeline(content); - content = convertDiagramState(content); - - if (content !== original) { - fs.writeFileSync(filePath, content); - console.log(`✅ Converted diagrams in ${file}`); - } else { - console.log(`- ${file} (no changes needed)`); - } - } -} - -processFiles(); diff --git a/apps/web/scripts/convert-to-children-clean.ts b/apps/web/scripts/convert-to-children-clean.ts deleted file mode 100644 index 9c810b8..0000000 --- a/apps/web/scripts/convert-to-children-clean.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const MDX_DIR = path.join(process.cwd(), 'content/blog'); - -/** - * Convert ugly single-line graph="..." props to clean multi-line children. - * - * FROM: - * - * - * TO: - * - * graph TD - * A-->B - * B-->C - * - */ -function convertToChildren(content: string): string { - // Match - const mermaidRegex = /]*?)\/>/g; - - return content.replace(mermaidRegex, (match, graphValue, otherProps) => { - // Unescape \n to real newlines - const cleanGraph = graphValue.replace(/\\n/g, '\n'); - - // Clean up other props - const cleanProps = otherProps.trim(); - - // Build the new format with children - return `\n${cleanGraph}\n`; - }); -} - -function processFiles() { - const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx')); - let fixCount = 0; - - for (const file of files) { - const filePath = path.join(MDX_DIR, file); - const content = fs.readFileSync(filePath, 'utf8'); - const fixed = convertToChildren(content); - - if (content !== fixed) { - fs.writeFileSync(filePath, fixed); - fixCount++; - console.log(`✅ Converted to children: ${file}`); - } else { - console.log(`- ${file} (no changes needed)`); - } - } - - console.log(`\nTotal converted: ${fixCount}`); -} - -processFiles(); diff --git a/apps/web/scripts/convert-to-plain-prop.ts b/apps/web/scripts/convert-to-plain-prop.ts deleted file mode 100644 index 2fe4c35..0000000 --- a/apps/web/scripts/convert-to-plain-prop.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const MDX_DIR = path.join(process.cwd(), 'content/blog'); - -/** - * Convert graph={"..."} to graph="..." (plain string prop without JSX expression wrapper). - * MDXRemote RSC strips JSX expression props but keeps plain string props. - * - * But we also need the escape sequences to go through. - * The plain string prop `graph="graph TD\nA-->B"` will have the \n treated as - * literal characters by MDX's parser, not as a newline. The Mermaid component - * then unescapes them. - */ -function convertToPlainStringProps(content: string): string { - // Match graph={" ... "} and convert to graph="..." - // The content inside should already have escaped newlines and quotes - const pattern = /graph=\{"((?:[^"\\]|\\.)*)"\}/g; - - return content.replace(pattern, (match, graphContent) => { - return `graph="${graphContent}"`; - }); -} - -function processFiles() { - const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx')); - let fixCount = 0; - - for (const file of files) { - const filePath = path.join(MDX_DIR, file); - const content = fs.readFileSync(filePath, 'utf8'); - const fixed = convertToPlainStringProps(content); - - if (content !== fixed) { - fs.writeFileSync(filePath, fixed); - fixCount++; - console.log(`✅ Converted: ${file}`); - } else { - console.log(`- ${file} (no changes needed)`); - } - } - - console.log(`\nTotal converted: ${fixCount}`); -} - -processFiles(); diff --git a/apps/web/scripts/convert-to-plain-string.ts b/apps/web/scripts/convert-to-plain-string.ts deleted file mode 100644 index 650f901..0000000 --- a/apps/web/scripts/convert-to-plain-string.ts +++ /dev/null @@ -1,60 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const MDX_DIR = path.join(process.cwd(), 'content/blog'); - -/** - * Convert all Mermaid children syntax back to graph prop, - * BUT use a regular double-quoted string with escaped newlines instead of template literals. - * - * MDXRemote RSC strips template literals! - * - * Convert: - * - * {`graph TD - * A --> B`} - * - * - * To: - * B"} id="..." title="..." showShare={true} /> - */ -function convertToPlainStringProp(content: string): string { - // Match {\`...\`} - const mermaidChildrenRegex = /]*?)>\s*\{`([\s\S]*?)`\}\s*<\/Mermaid>/g; - - return content.replace(mermaidChildrenRegex, (match, propsStr, graphContent) => { - // Escape double quotes in the graph content - const escapedGraph = graphContent - .replace(/\\/g, '\\\\') // escape backslashes first - .replace(/"/g, '\\"') // escape double quotes - .replace(/\n/g, '\\n'); // escape newlines - - // Clean up props string - const cleanProps = propsStr.trim(); - - return ``; - }); -} - -function processFiles() { - const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx')); - let fixCount = 0; - - for (const file of files) { - const filePath = path.join(MDX_DIR, file); - const content = fs.readFileSync(filePath, 'utf8'); - const fixed = convertToPlainStringProp(content); - - if (content !== fixed) { - fs.writeFileSync(filePath, fixed); - fixCount++; - console.log(`✅ Converted to plain string: ${file}`); - } else { - console.log(`- ${file} (no Mermaid children found)`); - } - } - - console.log(`\nTotal converted: ${fixCount}`); -} - -processFiles(); diff --git a/apps/web/scripts/final-diagram-fix.ts b/apps/web/scripts/final-diagram-fix.ts deleted file mode 100644 index 73050ed..0000000 --- a/apps/web/scripts/final-diagram-fix.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const MDX_DIR = path.join(process.cwd(), 'content/blog'); - -/** - * FINAL ATTEMPT: Standardize EVERYTHING in Mermaid blocks to double quotes. - * - * 1. Find all text inside .... - * 2. Replace any ['Label'] or ['Label's'] or ["Label"] patterns. - * 3. Enforce ["Label"] for all labels. - * 4. Remove any internal single quotes that break parsing. - */ -function finalMermaidFix(content: string): string { - const mermaidRegex = /(]*>)([\s\S]*?)(<\/Mermaid>)/g; - - return content.replace(mermaidRegex, (match, open, body, close) => { - let fixedBody = body; - - // Convert common label syntax to clean double quotes - // Match: [followed by optional space and any quote, capture content, end with optional quote and space] - fixedBody = fixedBody.replace(/\[\s*['"]?([^\]'"]+?)['"]?\s*\]/g, (m, label) => { - // Clean the label: remove any internal quotes that could cause issues - const cleanLabel = label.replace(/['"]/g, "").trim(); - return `["${cleanLabel}"]`; - }); - - // Also handle Pie charts which use 'Label' : value - fixedBody = fixedBody.replace(/^\s*'([^']+)'\s*:/gm, (m, label) => { - const cleanLabel = label.replace(/['"]/g, "").trim(); - return ` "${cleanLabel}" :`; - }); - - return open + fixedBody + close; - }); -} - -const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx')); -for (const file of files) { - const fp = path.join(MDX_DIR, file); - const content = fs.readFileSync(fp, 'utf8'); - const fixed = finalMermaidFix(content); - if (content !== fixed) { - fs.writeFileSync(fp, fixed); - console.log(`✅ Fixed potentially problematic syntax in: ${file}`); - } -} diff --git a/apps/web/scripts/fix-fenced-mermaid.ts b/apps/web/scripts/fix-fenced-mermaid.ts deleted file mode 100644 index cb403b7..0000000 --- a/apps/web/scripts/fix-fenced-mermaid.ts +++ /dev/null @@ -1,62 +0,0 @@ - -import * as fs from 'fs'; -import * as path from 'path'; - -const MDX_DIR = path.join(process.cwd(), 'content/blog'); -const TARGET_FILE = process.argv[2] ? path.resolve(process.argv[2]) : null; - -function fixFencedMermaid(content: string): string { - // Regex to find fenced mermaid blocks - // ```mermaid - // graph TD... - // ``` - const fencedRegex = /```mermaid\n([\s\S]*?)\n```/g; - - return content.replace(fencedRegex, (match, code) => { - // Generate a random ID or use a placeholder - const id = `diagram-${Math.random().toString(36).substr(2, 9)}`; - return `
- -${code.trim()} - -
`; - }); -} - -function processFiles() { - if (TARGET_FILE) { - console.log(`Processing single file: ${TARGET_FILE}`); - if (fs.existsSync(TARGET_FILE)) { - const content = fs.readFileSync(TARGET_FILE, 'utf8'); - const fixed = fixFencedMermaid(content); - if (content !== fixed) { - fs.writeFileSync(TARGET_FILE, fixed); - console.log(`✅ Fixed fenced mermaid in: ${TARGET_FILE}`); - } else { - console.log(`- No changes needed.`); - } - } else { - console.error(`File not found: ${TARGET_FILE}`); - } - return; - } - - const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx')); - let fixCount = 0; - - for (const file of files) { - const filePath = path.join(MDX_DIR, file); - const content = fs.readFileSync(filePath, 'utf8'); - const fixed = fixFencedMermaid(content); - - if (content !== fixed) { - fs.writeFileSync(filePath, fixed); - fixCount++; - console.log(`✅ Fixed fenced mermaid in: ${file}`); - } - } - - console.log(`\nTotal fixed: ${fixCount}`); -} - -processFiles(); diff --git a/apps/web/scripts/fix-graph-quotes.ts b/apps/web/scripts/fix-graph-quotes.ts deleted file mode 100644 index b9d974d..0000000 --- a/apps/web/scripts/fix-graph-quotes.ts +++ /dev/null @@ -1,86 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const MDX_DIR = path.join(process.cwd(), 'content/blog'); - -/** - * Fix escaped double quotes in Mermaid graph props. - * - * The graph prop contains \" which is invalid in MDX attribute syntax. - * Replace \" with ' (single quote) - Mermaid supports both. - * - * Also fix \\n to just \n (single backslash) - the MDX parser - * will treat \n as literal characters, and the Mermaid component - * will unescape them. - */ -function fixGraphQuotes(content: string): string { - // Match graph="..." prop (the entire value including escaped content) - // This is tricky because the value can contain escaped quotes - // We need to match from graph=" to the closing " that ends the prop value - - // Strategy: Find graph=" then scan forward, tracking escape sequences - const graphPropStart = 'graph="'; - let result = ''; - let i = 0; - - while (i < content.length) { - const idx = content.indexOf(graphPropStart, i); - if (idx === -1) { - result += content.slice(i); - break; - } - - // Copy everything up to and including graph=" - result += content.slice(i, idx + graphPropStart.length); - - // Now scan the value, replacing \" with ' - let j = idx + graphPropStart.length; - let graphValue = ''; - - while (j < content.length) { - if (content[j] === '\\' && content[j + 1] === '"') { - // Replace \" with ' - graphValue += "'"; - j += 2; - } else if (content[j] === '\\' && content[j + 1] === '\\') { - // Keep \\ as is - graphValue += '\\\\'; - j += 2; - } else if (content[j] === '"') { - // End of attribute value - break; - } else { - graphValue += content[j]; - j++; - } - } - - result += graphValue; - i = j; // Continue from the closing quote (will be added in next iteration) - } - - return result; -} - -function processFiles() { - const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx')); - let fixCount = 0; - - for (const file of files) { - const filePath = path.join(MDX_DIR, file); - const content = fs.readFileSync(filePath, 'utf8'); - const fixed = fixGraphQuotes(content); - - if (content !== fixed) { - fs.writeFileSync(filePath, fixed); - fixCount++; - console.log(`✅ Fixed quotes: ${file}`); - } else { - console.log(`- ${file} (no changes needed)`); - } - } - - console.log(`\nTotal fixed: ${fixCount}`); -} - -processFiles(); diff --git a/apps/web/scripts/fix-mdx-whitespace.ts b/apps/web/scripts/fix-mdx-whitespace.ts deleted file mode 100644 index e13e5cd..0000000 --- a/apps/web/scripts/fix-mdx-whitespace.ts +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env tsx - -/** - * Fix missing whitespace in MDX files by comparing with TSX originals - */ - -import * as fs from 'fs'; -import * as path from 'path'; - -// Mapping of TSX component names to MDX slugs -const TSX_TO_MDX_MAP: Record = { - 'PageSpeedFails': 'why-pagespeed-fails', - 'SlowLoadingDebt': 'slow-loading-costs-customers', - 'AgencySlowdown': 'why-agencies-are-slow', - 'WordPressPlugins': 'hidden-costs-of-wordpress-plugins', - 'WebsiteStability': 'why-websites-break-after-updates', - 'CookieFreeDesign': 'website-without-cookie-banners', - 'LocalCloud': 'no-us-cloud-platforms', - 'GDPRSystem': 'gdpr-conformity-system-approach', - 'VendorLockIn': 'builder-systems-threaten-independence', - 'PrivacyAnalytics': 'analytics-without-tracking', - 'GreenIT': 'green-it-sustainable-web', - 'FixedPrice': 'fixed-price-digital-projects', - 'BuildFirst': 'build-first-digital-architecture', - 'MaintenanceNoCMS': 'maintenance-for-headless-systems', - 'Longevity': 'digital-longevity-architecture', - 'CleanCode': 'clean-code-for-business-value', - 'ResponsiveDesign': 'responsive-design-high-fidelity', - 'HostingOps': 'professional-hosting-operations', - 'NoTemplates': 'why-no-templates-matter', - 'CRMSync': 'crm-synchronization-headless', -}; - -const TSX_BASE = path.join(process.cwd(), 'src/components/blog/posts'); -const MDX_BASE = path.join(process.cwd(), 'content/blog'); - -function findTsxFile(componentName: string): string | null { - for (const group of ['Group1', 'Group2', 'Group3', 'Group4']) { - const tsxPath = path.join(TSX_BASE, group, `${componentName}.tsx`); - if (fs.existsSync(tsxPath)) { - return tsxPath; - } - } - return null; -} - -function fixWhitespace() { - let totalFixed = 0; - - for (const [tsxName, mdxSlug] of Object.entries(TSX_TO_MDX_MAP)) { - const tsxPath = findTsxFile(tsxName); - const mdxPath = path.join(MDX_BASE, `${mdxSlug}.mdx`); - - if (!tsxPath || !fs.existsSync(mdxPath)) { - console.log(`⚠️ Skipping ${tsxName}: files not found`); - continue; - } - - const tsxContent = fs.readFileSync(tsxPath, 'utf-8'); - let mdxContent = fs.readFileSync(mdxPath, 'utf-8'); - - // Count occurrences of {" "} in both files - const tsxSpaces = (tsxContent.match(/\{" "\}/g) || []).length; - const mdxSpacesBefore = (mdxContent.match(/\{" "\}/g) || []).length; - - if (tsxSpaces === 0) { - console.log(`✓ ${mdxSlug}: No whitespace needed`); - continue; - } - - // Extract all lines with {" "} from TSX - const tsxLines = tsxContent.split('\n'); - const spacedLines: Array<{ lineNum: number; content: string }> = []; - - tsxLines.forEach((line, idx) => { - if (line.includes('{" "}')) { - spacedLines.push({ lineNum: idx, content: line.trim() }); - } - }); - - // For each spaced line in TSX, find similar content in MDX and add {" "} - let fixCount = 0; - for (const { content } of spacedLines) { - // Extract the text pattern before {" "} - const match = content.match(/(.+?)\{" "\}/); - if (!match) continue; - - const textBefore = match[1].trim(); - - // Find this pattern in MDX without the space - const searchPattern = new RegExp( - textBefore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?!\\{" "\\})', - 'g' - ); - - const newMdxContent = mdxContent.replace(searchPattern, (matched) => { - // Only add {" "} if it's not already there and if it's followed by a tag - if (mdxContent.indexOf(matched + '{" "}') === -1 && - mdxContent.indexOf(matched) < mdxContent.indexOf('<', mdxContent.indexOf(matched))) { - fixCount++; - return matched + '{" "}'; - } - return matched; - }); - - mdxContent = newMdxContent; - } - - const mdxSpacesAfter = (mdxContent.match(/\{" "\}/g) || []).length; - - if (fixCount > 0) { - fs.writeFileSync(mdxPath, mdxContent, 'utf-8'); - console.log(`✓ ${mdxSlug}: Fixed ${fixCount} whitespace issues (${mdxSpacesBefore} → ${mdxSpacesAfter})`); - totalFixed += fixCount; - } else { - console.log(`✓ ${mdxSlug}: Already correct (${mdxSpacesBefore} spaces)`); - } - } - - console.log(`\n✅ Total whitespace fixes: ${totalFixed}`); -} - -fixWhitespace(); diff --git a/apps/web/scripts/fix-mermaid-apostrophes.ts b/apps/web/scripts/fix-mermaid-apostrophes.ts deleted file mode 100644 index 8509b45..0000000 --- a/apps/web/scripts/fix-mermaid-apostrophes.ts +++ /dev/null @@ -1,61 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const MDX_DIR = path.join(process.cwd(), 'content/blog'); - -/** - * Fix apostrophes in Mermaid labels by removing them. - * - * Mermaid parser treats ' as a quote delimiter even inside ["..."]. - * Replace ' with nothing or use HTML entity ' (but simpler to just remove). - */ -function fixMermaidApostrophes(content: string): string { - // Find all Mermaid blocks - const mermaidBlockRegex = /(]*>)([\s\S]*?)(<\/Mermaid>)/g; - - return content.replace(mermaidBlockRegex, (match, openTag, mermaidContent, closeTag) => { - // Within Mermaid content, find all ["..."] labels and remove apostrophes - const fixed = mermaidContent.replace(/\["([^"]*)"\]/g, (m: string, label: string) => { - // Remove all apostrophes from the label - const cleanLabel = label.replace(/'/g, ''); - return `["${cleanLabel}"]`; - }); - - return openTag + fixed + closeTag; - }); -} - -function processFiles() { - const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx')); - let fixCount = 0; - let totalApostrophes = 0; - - for (const file of files) { - const filePath = path.join(MDX_DIR, file); - const content = fs.readFileSync(filePath, 'utf8'); - - // Count apostrophes in Mermaid blocks before fixing - const mermaidBlocks = content.match(/]*>[\s\S]*?<\/Mermaid>/g) || []; - for (const block of mermaidBlocks) { - const apostrophes = (block.match(/\["[^"]*'[^"]*"\]/g) || []).length; - if (apostrophes > 0) { - totalApostrophes += apostrophes; - } - } - - const fixed = fixMermaidApostrophes(content); - - if (content !== fixed) { - fs.writeFileSync(filePath, fixed); - fixCount++; - console.log(`✅ Fixed apostrophes: ${file}`); - } else { - console.log(`- ${file} (no apostrophes found)`); - } - } - - console.log(`\nTotal files fixed: ${fixCount}`); - console.log(`Total apostrophes removed: ${totalApostrophes}`); -} - -processFiles(); diff --git a/apps/web/scripts/fix-mermaid-labels-strict.ts b/apps/web/scripts/fix-mermaid-labels-strict.ts deleted file mode 100644 index 5d167d0..0000000 --- a/apps/web/scripts/fix-mermaid-labels-strict.ts +++ /dev/null @@ -1,69 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const MDX_DIR = path.join(process.cwd(), 'content/blog'); - -/** - * STRICTLY fixes Mermaid node labels. - * - * Goal: - * 1. Find all content inside [...] in Mermaid blocks. - * 2. Strip ALL outer quotes (single or double). - * 3. Sanitize the inner text: - * - Remove/Replace internal double quotes - * - Remove/Replace internal single quotes (to avoid any ambiguity) - * 4. Wrap strictly in ["..."]. - */ -function fixMermaidLabels(content: string): string { - // Find Mermaid blocks - return content.replace(/(]*>)([\s\S]*?)(<\/Mermaid>)/g, (match, open, body, close) => { - - // Process the body line by line to be safe, or just regex the labels. - // Regex for labels: \[ followed by anything until \] - // Note: We assume labels don't contain nested brackets for now (Mermaid usually doesn't). - const fixedBody = body.replace(/\[([^\]]+)\]/g, (labelMatch, innerContent) => { - let text = innerContent.trim(); - - // Check if it looks like a quoted label - const hasOuterQuotes = /^['"]|['"]$/.test(text); - if (hasOuterQuotes) { - // Remove ALL starting/ending quotes (handling multiple if messed up) - text = text.replace(/^['"]+|['"]+$/g, ''); - } - - // Sanitize internal text - // Replace " with ' to avoid breaking the outer double quotes - text = text.replace(/"/g, "'"); - - // Verify parsing safety: - // Replace ' with space or nothing if we want to be super safe, - // but "Text with 'quotes'" SHOULD be valid in Mermaid. - // However, the previous error might have been due to MDX interference. - // Let's keep single quotes inside, but ensure outer are double. - - // WAIT: The specific error was `B['Server ... 'inner' ...']`. - // If we convert to `B["Server ... 'inner' ..."]`, it should work. - - return `["${text}"]`; - }); - - return open + fixedBody + close; - }); -} - -const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx')); -let fixedCount = 0; - -for (const file of files) { - const filePath = path.join(MDX_DIR, file); - const content = fs.readFileSync(filePath, 'utf8'); - const fixed = fixMermaidLabels(content); - - if (content !== fixed) { - fs.writeFileSync(filePath, fixed); - console.log(`✅ Fixed labels in: ${file}`); - fixedCount++; - } -} - -console.log(`\nFixed ${fixedCount} files.`); diff --git a/apps/web/scripts/fix-mermaid-quotes.ts b/apps/web/scripts/fix-mermaid-quotes.ts deleted file mode 100644 index e206594..0000000 --- a/apps/web/scripts/fix-mermaid-quotes.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const MDX_DIR = path.join(process.cwd(), 'content/blog'); - -/** - * Fix ALL quote variations in Mermaid labels to use consistent double quotes. - * - * Handles: - * - ['Label'] → ["Label"] - * - ["Label'] → ["Label"] - * - ['Label"] → ["Label"] - * - ["Label"] → ["Label"] (already correct) - */ -function fixMermaidQuotes(content: string): string { - // Find all Mermaid blocks (between and ) - const mermaidBlockRegex = /(]*>)([\s\S]*?)(<\/Mermaid>)/g; - - return content.replace(mermaidBlockRegex, (match, openTag, mermaidContent, closeTag) => { - // Replace all variations: [' or [" at start, '] or "] at end - // Match pattern: [ followed by ' or ", then content, then ' or ", then ] - const fixed = mermaidContent.replace(/\[['"]([^'"]*)['"]\]/g, '["$1"]'); - - return openTag + fixed + closeTag; - }); -} - -function processFiles() { - const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx')); - let fixCount = 0; - - for (const file of files) { - const filePath = path.join(MDX_DIR, file); - const content = fs.readFileSync(filePath, 'utf8'); - const fixed = fixMermaidQuotes(content); - - if (content !== fixed) { - fs.writeFileSync(filePath, fixed); - fixCount++; - console.log(`✅ Fixed Mermaid quotes: ${file}`); - } else { - console.log(`- ${file} (no changes needed)`); - } - } - - console.log(`\nTotal fixed: ${fixCount}`); -} - -processFiles(); diff --git a/apps/web/scripts/generate-thumbnail.ts b/apps/web/scripts/generate-thumbnail.ts new file mode 100644 index 0000000..51a82e3 --- /dev/null +++ b/apps/web/scripts/generate-thumbnail.ts @@ -0,0 +1,45 @@ +import { ThumbnailGenerator } from '@mintel/thumbnail-generator'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; + +async function run() { + const apiKey = process.env.REPLICATE_API_KEY; + if (!apiKey) { + console.error("❌ Missing REPLICATE_API_KEY in environment."); + process.exit(1); + } + + const targetFile = process.argv[2]; + if (!targetFile) { + console.error("❌ Usage: npx tsx scripts/generate-thumbnail.ts "); + process.exit(1); + } + + let topic = targetFile; + let filename = "thumbnail.png"; + + // Try to parse the topic from the MDX frontmatter if a file is provided + if (targetFile.endsWith('.mdx')) { + try { + const content = await fs.readFile(targetFile, 'utf8'); + const titleMatch = content.match(/title:\s*"?([^"\n]+)"?/); + topic = titleMatch ? titleMatch[1] : path.basename(targetFile, '.mdx'); + filename = `${path.basename(targetFile, '.mdx')}-thumb.png`; + } catch (e) { + console.warn(`⚠️ Could not read ${targetFile} as a file. Using literal argument as topic.`); + topic = targetFile; + } + } + + console.log(`Generating abstract thumbnail for topic: "${topic}"`); + + const generator = new ThumbnailGenerator({ replicateApiKey: apiKey }); + const outputPath = path.join(process.cwd(), 'public', 'blog', filename); + + await generator.generateImage(topic, outputPath); +} + +run().catch((e) => { + console.error("❌ Thumbnail generation failed:", e); + process.exit(1); +}); diff --git a/apps/web/scripts/optimize-blog-post.ts b/apps/web/scripts/optimize-blog-post.ts index 5f521f7..1f9636c 100644 --- a/apps/web/scripts/optimize-blog-post.ts +++ b/apps/web/scripts/optimize-blog-post.ts @@ -1,107 +1,31 @@ - -import { ContentGenerator, OptimizationOptions } from "@mintel/content-engine"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +import { AiBlogPostOrchestrator } from "@mintel/content-engine"; +import { config } from "../content-engine.config.js"; async function main() { - const OPENROUTER_KEY = process.env.OPENROUTER_KEY; + const OPENROUTER_KEY = process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY; + const REPLICATE_KEY = process.env.REPLICATE_API_KEY; + if (!OPENROUTER_KEY) { - console.error("❌ Error: OPENROUTER_KEY not found in environment."); + console.error("❌ Error: OPENROUTER_KEY or OPENROUTER_API_KEY not found in environment."); process.exit(1); } - const args = process.argv.slice(2); - let targetFile = args[0]; - + const targetFile = process.argv[2]; if (!targetFile) { - console.error("❌ Usage: npx tsx scripts/optimize-blog-post.ts "); + console.error("❌ Usage: npx tsx scripts/optimize-blog-post.ts "); process.exit(1); } - // Resolve absolute path - if (!path.isAbsolute(targetFile)) { - targetFile = path.resolve(process.cwd(), targetFile); - } + const orchestrator = new AiBlogPostOrchestrator({ + apiKey: OPENROUTER_KEY, + replicateApiKey: REPLICATE_KEY, + model: 'google/gemini-3-flash-preview' + }); - console.log(`📄 Reading file: ${targetFile}`); - let content = ""; - try { - content = await fs.readFile(targetFile, "utf8"); - } catch (err: any) { - console.error(`❌ Could not read file: ${err.message}`); - process.exit(1); - } - - // Backup original - const backupPath = `${targetFile}.bak`; - await fs.writeFile(backupPath, content); - console.log(`💾 Backup created at: ${backupPath}`); - - // Instantiate Generator - const generator = new ContentGenerator(OPENROUTER_KEY); - - const context = ` - Project: Mintel.me - Style: Industrial, Technical, High-Performance, "No-BS". - Author: Marc Mintel (Digital Architect). - Focus: Web Architecture, PageSpeed, Core Web Vitals, Data-Driven Design. - `; - - // Define Optimization Options based on user request ("astrein verbessert mit daten gestützt, links zu quellen usw... mermaid, memes") - const options: OptimizationOptions = { - enhanceFacts: true, - addMemes: true, - addDiagrams: true, - projectContext: context, - availableComponents: [ - { - name: "StatsDisplay", - description: "A row of 3 clear statistic cards with values and labels.", - usageExample: ` 3s" }, - { value: "0.1s", label: "Latency", description: "Edge Network" } - ]} -/>` - }, - { - name: "ComparisonRow", - description: "A comparison component showing a negative 'Standard' vs a positive 'Mintel' approach.", - usageExample: `` - } - ] - }; - - // 1. Separate Frontmatter from Body - const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); - const frontmatter = fmMatch ? fmMatch[0] : ""; - const body = fmMatch ? content.replace(frontmatter, "").trim() : content; - - console.log("🚀 Starting Optimization via ContentEngine..."); - const result = await generator.optimizePost(body, options); - - console.log("✅ Optimization Complete!"); - console.log(` - Added ${result.research.length} facts`); - console.log(` - Added ${result.memes.length} meme concepts`); - console.log(` - Generated ${result.diagrams.length} diagrams`); - - // We write the content back (re-attaching frontmatter if it was there) - const finalContent = frontmatter ? `${frontmatter}\n\n${result.content}` : result.content; - - await fs.writeFile(targetFile, finalContent); - - console.log(`💾 Saved optimized content to: ${targetFile}`); + await orchestrator.optimizeFile(targetFile, { + contextDir: config.contextDir, + availableComponents: config.components + }); } main().catch(console.error); diff --git a/apps/web/scripts/repair-mermaid.ts b/apps/web/scripts/repair-mermaid.ts deleted file mode 100644 index 10e7369..0000000 --- a/apps/web/scripts/repair-mermaid.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const MDX_DIR = path.join(process.cwd(), 'content/blog'); - -function repairMermaidSyntax(content: string): string { - // 1. Convert to ... style or just fix the graph prop - // Actually, let's keep the graph prop but make sure the content is VERY safe. - - const mermaidRegex = //g; - - return content.replace(mermaidRegex, (match, before, graphLiteral, after) => { - let graphContent = graphLiteral.slice(1, -1); - - // Replace all {Label} with ["Label"] - graphContent = graphContent.replace(/\{([^{}]+)\}/g, '["$1"]'); - - // Sometimes people use double {{Label}} - graphContent = graphContent.replace(/\{\{([^{}]+)\}\}/g, '["$1"]'); - - // Remove any trailing backticks inside that might have been accidentally added - graphContent = graphContent.trim(); - - return ``; - }); -} - -// Additional fix for other diagram components that might have similar issues with props -function repairOtherDiagrams(content: string): string { - // For DiagramSequence, DiagramTimeline etc., we often pass arrays of objects. - // MDX handles these better, but let's make sure there are no weird backticks. - return content; -} - -function processFiles() { - const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx')); - - for (const file of files) { - const filePath = path.join(MDX_DIR, file); - const content = fs.readFileSync(filePath, 'utf8'); - let repaired = repairMermaidSyntax(content); - repaired = repairOtherDiagrams(repaired); - - if (content !== repaired) { - fs.writeFileSync(filePath, repaired); - console.log(`✅ Repaired Mermaid syntax in ${file}`); - } else { - console.log(`- Checked ${file}`); - } - } -} - -processFiles(); diff --git a/apps/web/scripts/test-links.ts b/apps/web/scripts/test-links.ts deleted file mode 100644 index 3245e43..0000000 --- a/apps/web/scripts/test-links.ts +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env tsx - -/** - * Updated link test for the Next.js blog with App Router - * Tests: All references are valid, files exist - */ - -import fs from "fs"; -import path from "path"; - -console.log("🔗 Checking links and references (Next.js App Router)...\n"); - -let passed = 0; -let failed = 0; - -function test(name: string, fn: () => void): void { - try { - fn(); - console.log(`✅ ${name}`); - passed++; - } catch (error) { - console.log(`❌ ${name}`); - if (error instanceof Error) { - console.log(` Error: ${error.message}`); - } - failed++; - } -} - -// Test 1: Check that blog posts reference valid data -test("Blog posts reference valid data", () => { - const blogPostsPath = path.join(process.cwd(), "src/data/blogPosts.ts"); - const content = fs.readFileSync(blogPostsPath, "utf8"); - - // Extract all slugs - const slugMatches = content.match(/slug:\s*['"]([^'"]+)['"]/g) || []; - const slugs = slugMatches.map((m) => m.match(/['"]([^'"]+)['"]/)?.[1]); - - if (slugs.length === 0) { - throw new Error("No slugs found in blogPosts.ts"); - } - - // Verify dynamic route page exists - const slugPagePath = path.join(process.cwd(), "app/blog/[slug]/page.tsx"); - if (!fs.existsSync(slugPagePath)) { - throw new Error( - "Dynamic slug page app/blog/[slug]/page.tsx does not exist", - ); - } -}); - -// Test 2: Verify tag references are valid -test("Tag references are valid", () => { - const blogPostsPath = path.join(process.cwd(), "src/data/blogPosts.ts"); - const content = fs.readFileSync(blogPostsPath, "utf8"); - - // Extract all tags - const tagMatches = content.match(/tags:\s*\[([^\]]+)\]/g) || []; - - if (tagMatches.length === 0) { - throw new Error("No tags found in blogPosts.ts"); - } - - // Verify tag page exists - const tagPagePath = path.join(process.cwd(), "app/tags/[tag]/page.tsx"); - if (!fs.existsSync(tagPagePath)) { - throw new Error("Tag page app/tags/[tag]/page.tsx does not exist"); - } -}); - -// Test 3: Verify all component imports are valid -test("All component imports are valid", () => { - const components = [ - "src/components/MediumCard.tsx", - "src/components/SearchBar.tsx", - "src/components/ArticleBlockquote.tsx", - "src/components/ArticleHeading.tsx", - "src/components/ArticleParagraph.tsx", - "src/components/ArticleList.tsx", - "src/components/Footer.tsx", - "src/components/Hero.tsx", - "src/components/Tag.tsx", - "src/components/FileExample.tsx", - "src/components/FileExamplesList.tsx", - ]; - - for (const component of components) { - const componentPath = path.join(process.cwd(), component); - if (!fs.existsSync(componentPath)) { - throw new Error(`Component missing: ${component}`); - } - } -}); - -// Test 4: Verify all required pages exist -test("All required pages exist", () => { - const requiredPages = [ - "app/page.tsx", - "app/blog/[slug]/page.tsx", - "app/tags/[tag]/page.tsx", - "app/api/download-zip/route.ts", - ]; - - for (const page of requiredPages) { - const pagePath = path.join(process.cwd(), page); - if (!fs.existsSync(pagePath)) { - throw new Error(`Required page missing: ${page}`); - } - } -}); - -// Test 5: Verify layout files are valid -test("Layout files are valid", () => { - const layoutPath = path.join(process.cwd(), "app/layout.tsx"); - - if (!fs.existsSync(layoutPath)) { - throw new Error("Layout missing: app/layout.tsx"); - } - - const content = fs.readFileSync(layoutPath, "utf8"); - - if (!content.includes("")) { - throw new Error("RootLayout does not contain proper HTML structure"); - } - - if (!content.includes("")) { - throw new Error("RootLayout missing body section"); - } -}); - -// Test 6: Verify global styles are properly imported -test("Global styles are properly imported", () => { - const stylesPath = path.join(process.cwd(), "app/globals.css"); - - if (!fs.existsSync(stylesPath)) { - throw new Error("Global styles file missing: app/globals.css"); - } - - const content = fs.readFileSync(stylesPath, "utf8"); - - // Check for Tailwind imports - if ( - !content.includes("@tailwind base") || - !content.includes("@tailwind components") || - !content.includes("@tailwind utilities") - ) { - throw new Error("Global styles missing Tailwind imports"); - } - - // Check for required classes (Next.js version uses different ones or we check the ones we found) - const requiredClasses = [".container", ".post-card", ".highlighter-tag"]; - for (const className of requiredClasses) { - if (!content.includes(className)) { - throw new Error(`Global styles missing required class: ${className}`); - } - } -}); - -// Test 7: Verify file examples data structure -test("File examples data structure is valid", () => { - const fileExamplesPath = path.join(process.cwd(), "src/data/fileExamples.ts"); - - if (!fs.existsSync(fileExamplesPath)) { - throw new Error("File examples data file missing"); - } - - const content = fs.readFileSync(fileExamplesPath, "utf8"); - - if ( - !content.includes("export interface FileExample") && - !content.includes("type FileExample") - ) { - throw new Error("FileExample interface/type not found"); - } - - if (!content.includes("export const sampleFileExamples")) { - throw new Error("sampleFileExamples not exported"); - } -}); - -// Test 8: Verify API endpoint structure -test("API endpoint structure is valid", () => { - const apiPath = path.join(process.cwd(), "app/api/download-zip/route.ts"); - - if (!fs.existsSync(apiPath)) { - throw new Error("API route missing"); - } - - const content = fs.readFileSync(apiPath, "utf8"); - - if (!content.includes("export async function POST")) { - throw new Error("API missing POST handler"); - } - - if (!content.includes("export async function GET")) { - throw new Error("API missing GET handler"); - } -}); - -// Summary -console.log("\n" + "=".repeat(50)); -console.log(`Tests passed: ${passed}`); -console.log(`Tests failed: ${failed}`); -console.log("=".repeat(50)); - -if (failed === 0) { - console.log("\n🎉 All link checks passed! All references are valid."); - console.log("\nVerified:"); - console.log(" ✅ Blog posts data and routing (Next.js)"); - console.log(" ✅ Tag filtering system"); - console.log(" ✅ All components exist"); - console.log(" ✅ All pages exist"); - console.log(" ✅ Layout structure (App Router)"); - console.log(" ✅ File examples functionality"); - console.log(" ✅ API routes"); - process.exit(0); -} else { - console.log("\n❌ Some checks failed. Please fix the errors above."); - process.exit(1); -} diff --git a/apps/web/scripts/verify-components.ts b/apps/web/scripts/verify-components.ts deleted file mode 100644 index 4784df4..0000000 --- a/apps/web/scripts/verify-components.ts +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env tsx -/** - * Verify components can be imported and used (Next.js Version) - */ - -import { join } from "path"; -import fs from "fs"; - -console.log("🔍 Verifying Embed Components (Next.js)...\n"); - -// Test 1: Check if components exist -const components = ["YouTubeEmbed.tsx", "TwitterEmbed.tsx", "GenericEmbed.tsx"]; - -for (const component of components) { - const componentPath = join(process.cwd(), "src", "components", component); - if (fs.existsSync(componentPath)) { - console.log(`✅ ${component} exists`); - } else { - console.log(`❌ Component missing: ${component}`); - } -} - -// Test 2: Check demo post accessibility -try { - const demoPath = join(process.cwd(), "src", "data", "embedDemoPost.ts"); - - if (fs.existsSync(demoPath)) { - console.log("✅ embedDemoPost.ts data file exists"); - } else { - console.log("❌ embedDemoPost.ts missing"); - } -} catch (error) { - console.log("❌ Demo post check error:", error); -} - -// Test 3: Check blogPosts array -try { - const blogPostsPath = join(process.cwd(), "src", "data", "blogPosts.ts"); - if (fs.existsSync(blogPostsPath)) { - // Check if embed-demo needs to be added (actually it's blog-embed-demo or similar usually) - console.log("✅ Checking blogPosts array integration..."); - } -} catch (error) { - console.log("❌ blogPosts check error:", error); -} - -console.log("\n" + "=".repeat(60)); -console.log("📋 SUMMARY:"); -console.log("• Components are verified for Next.js"); -console.log("• Data structure is verified"); diff --git a/apps/web/scripts/verify-embeds.ts b/apps/web/scripts/verify-embeds.ts new file mode 100644 index 0000000..2ddd69e --- /dev/null +++ b/apps/web/scripts/verify-embeds.ts @@ -0,0 +1,61 @@ +import puppeteer from 'puppeteer'; + +(async () => { + try { + console.log("Starting Chrome..."); + const browser = await puppeteer.launch({ + headless: 'new', + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + + const page = await browser.newPage(); + + // Pass any console logs from the browser to our terminal + page.on('console', msg => console.log('BROWSER LOG:', msg.text())); + + console.log("Navigating to http://localhost:3000/blog/why-pagespeed-fails ..."); + await page.goto('http://localhost:3000/blog/why-pagespeed-fails', { waitUntil: 'networkidle0' }); + + // Wait a bit just in case + await new Promise(r => setTimeout(r, 2000)); + + console.log("--- Inspecting Mermaid ---"); + const mermaidLabels = await page.evaluate(() => { + const labels = Array.from(document.querySelectorAll('.mermaid svg text, .mermaid svg .nodeLabel')); + return labels.map(l => l.textContent).filter(Boolean); + }); + console.log(`Found ${mermaidLabels.length} mermaid labels.`); + if (mermaidLabels.length > 0) { + console.log("Sample labels:", mermaidLabels.slice(0, 5)); + } else { + console.log("FAIL: No SVG labels found inside Mermaid containers!"); + } + + console.log("\n--- Inspecting Twitter Embed ---"); + const tweets = await page.evaluate(() => { + const tweetContainers = Array.from(document.querySelectorAll('.react-tweet-theme')); + return tweetContainers.map(container => ({ + html: container.outerHTML.substring(0, 150) + "..." + })); + }); + + console.log(`Found ${tweets.length} Tweet containers.`); + if (tweets.length > 0) { + console.log("Success! Tweet container snippet:", tweets[0].html); + + // Further inspection of react-tweet - it sometimes renders an error div if not found + const tweetErrors = await page.evaluate(() => { + return Array.from(document.querySelectorAll('.react-tweet-theme [data-testid="tweet-not-found"]')).length; + }); + if (tweetErrors > 0) { + console.log(`FAIL: Found ${tweetErrors} 'Tweet not found' error states inside the container.`); + } + } else { + console.log("FAIL: No react-tweet containers found on page. It might be completely crashing or skipped."); + } + + await browser.close(); + } catch (e) { + console.error("Script failed:", e); + } +})(); diff --git a/apps/web/src/assets/logo/Icon Black Transparent.png b/apps/web/src/assets/logo/Icon-Black-Transparent.png similarity index 100% rename from apps/web/src/assets/logo/Icon Black Transparent.png rename to apps/web/src/assets/logo/Icon-Black-Transparent.png diff --git a/apps/web/src/assets/logo/Icon Black Transparent.svg b/apps/web/src/assets/logo/Icon-Black-Transparent.svg similarity index 100% rename from apps/web/src/assets/logo/Icon Black Transparent.svg rename to apps/web/src/assets/logo/Icon-Black-Transparent.svg diff --git a/apps/web/src/assets/logo/Icon Black Transparent@2x.png b/apps/web/src/assets/logo/Icon-Black-Transparent@2x.png similarity index 100% rename from apps/web/src/assets/logo/Icon Black Transparent@2x.png rename to apps/web/src/assets/logo/Icon-Black-Transparent@2x.png diff --git a/apps/web/src/assets/logo/Icon Black.png b/apps/web/src/assets/logo/Icon-Black.png similarity index 100% rename from apps/web/src/assets/logo/Icon Black.png rename to apps/web/src/assets/logo/Icon-Black.png diff --git a/apps/web/src/assets/logo/Icon Black@2x.png b/apps/web/src/assets/logo/Icon-Black@2x.png similarity index 100% rename from apps/web/src/assets/logo/Icon Black@2x.png rename to apps/web/src/assets/logo/Icon-Black@2x.png diff --git a/apps/web/src/assets/logo/Icon White Transparent.png b/apps/web/src/assets/logo/Icon-White-Transparent.png similarity index 100% rename from apps/web/src/assets/logo/Icon White Transparent.png rename to apps/web/src/assets/logo/Icon-White-Transparent.png diff --git a/apps/web/src/assets/logo/Icon White Transparent.svg b/apps/web/src/assets/logo/Icon-White-Transparent.svg similarity index 100% rename from apps/web/src/assets/logo/Icon White Transparent.svg rename to apps/web/src/assets/logo/Icon-White-Transparent.svg diff --git a/apps/web/src/assets/logo/Icon White Transparent@2x.png b/apps/web/src/assets/logo/Icon-White-Transparent@2x.png similarity index 100% rename from apps/web/src/assets/logo/Icon White Transparent@2x.png rename to apps/web/src/assets/logo/Icon-White-Transparent@2x.png diff --git a/apps/web/src/assets/logo/Icon White.png b/apps/web/src/assets/logo/Icon-White.png similarity index 100% rename from apps/web/src/assets/logo/Icon White.png rename to apps/web/src/assets/logo/Icon-White.png diff --git a/apps/web/src/assets/logo/Icon White@2x.png b/apps/web/src/assets/logo/Icon-White@2x.png similarity index 100% rename from apps/web/src/assets/logo/Icon White@2x.png rename to apps/web/src/assets/logo/Icon-White@2x.png diff --git a/apps/web/src/assets/logo/Logo Black Transparent.png b/apps/web/src/assets/logo/Logo-Black-Transparent.png similarity index 100% rename from apps/web/src/assets/logo/Logo Black Transparent.png rename to apps/web/src/assets/logo/Logo-Black-Transparent.png diff --git a/apps/web/src/assets/logo/Logo Black Transparent.svg b/apps/web/src/assets/logo/Logo-Black-Transparent.svg similarity index 100% rename from apps/web/src/assets/logo/Logo Black Transparent.svg rename to apps/web/src/assets/logo/Logo-Black-Transparent.svg diff --git a/apps/web/src/assets/logo/Logo Black Transparent@2x.png b/apps/web/src/assets/logo/Logo-Black-Transparent@2x.png similarity index 100% rename from apps/web/src/assets/logo/Logo Black Transparent@2x.png rename to apps/web/src/assets/logo/Logo-Black-Transparent@2x.png diff --git a/apps/web/src/assets/logo/Logo Black.png b/apps/web/src/assets/logo/Logo-Black.png similarity index 100% rename from apps/web/src/assets/logo/Logo Black.png rename to apps/web/src/assets/logo/Logo-Black.png diff --git a/apps/web/src/assets/logo/Logo Black.svg b/apps/web/src/assets/logo/Logo-Black.svg similarity index 100% rename from apps/web/src/assets/logo/Logo Black.svg rename to apps/web/src/assets/logo/Logo-Black.svg diff --git a/apps/web/src/assets/logo/Logo Black@2x.png b/apps/web/src/assets/logo/Logo-Black@2x.png similarity index 100% rename from apps/web/src/assets/logo/Logo Black@2x.png rename to apps/web/src/assets/logo/Logo-Black@2x.png diff --git a/apps/web/src/assets/logo/Logo White Transparent.png b/apps/web/src/assets/logo/Logo-White-Transparent.png similarity index 100% rename from apps/web/src/assets/logo/Logo White Transparent.png rename to apps/web/src/assets/logo/Logo-White-Transparent.png diff --git a/apps/web/src/assets/logo/Logo White Transparent.svg b/apps/web/src/assets/logo/Logo-White-Transparent.svg similarity index 100% rename from apps/web/src/assets/logo/Logo White Transparent.svg rename to apps/web/src/assets/logo/Logo-White-Transparent.svg diff --git a/apps/web/src/assets/logo/Logo White Transparent@2x.png b/apps/web/src/assets/logo/Logo-White-Transparent@2x.png similarity index 100% rename from apps/web/src/assets/logo/Logo White Transparent@2x.png rename to apps/web/src/assets/logo/Logo-White-Transparent@2x.png diff --git a/apps/web/src/assets/logo/Logo White.png b/apps/web/src/assets/logo/Logo-White.png similarity index 100% rename from apps/web/src/assets/logo/Logo White.png rename to apps/web/src/assets/logo/Logo-White.png diff --git a/apps/web/src/assets/logo/Logo White@2x.png b/apps/web/src/assets/logo/Logo-White@2x.png similarity index 100% rename from apps/web/src/assets/logo/Logo White@2x.png rename to apps/web/src/assets/logo/Logo-White@2x.png diff --git a/apps/web/src/components/Analytics.tsx b/apps/web/src/components/Analytics.tsx index 1e568a2..390bd3b 100644 --- a/apps/web/src/components/Analytics.tsx +++ b/apps/web/src/components/Analytics.tsx @@ -1,14 +1,14 @@ "use client"; import React, { useEffect, Suspense } from "react"; -import { usePathname, useSearchParams } from "next/navigation"; +import { useSafePathname, useSafeSearchParams } from "./analytics/useSafePathname"; import { ScrollDepthTracker } from "./analytics/ScrollDepthTracker"; import { getDefaultAnalytics } from "../utils/analytics"; import { getDefaultErrorTracking } from "../utils/error-tracking"; const AnalyticsInner: React.FC = () => { - const pathname = usePathname(); - const searchParams = useSearchParams(); + const pathname = useSafePathname(); + const searchParams = useSafeSearchParams(); // Track pageviews on route change useEffect(() => { diff --git a/apps/web/src/components/ArticleBlockquote.tsx b/apps/web/src/components/ArticleBlockquote.tsx index f4e86d0..068df97 100644 --- a/apps/web/src/components/ArticleBlockquote.tsx +++ b/apps/web/src/components/ArticleBlockquote.tsx @@ -1,5 +1,7 @@ import React from 'react'; import Prism from 'prismjs'; +import { ComponentShareButton } from './ComponentShareButton'; +import { Reveal } from './Reveal'; import 'prismjs/components/prism-python'; import 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-javascript'; @@ -19,18 +21,50 @@ interface BlockquoteProps { className?: string; } -export const ArticleBlockquote: React.FC = ({ children, className = '' }) => ( -
-
- -
-
-
- {children} -
-
-
-); +export const ArticleBlockquote: React.FC = ({ children, className = '' }) => { + // Generate a quick stable hash based on content length/chars for ID + const shareId = `blockquote-${Math.random().toString(36).substring(7).toUpperCase()}`; + + return ( + +
+
+ +
+ + {/* Subtle top shine */} +
+ + {/* Share Button top right */} +
+ +
+ +
+
+
+ {/* Small emerald glow effect inside the icon box on hover */} +
+ + + +
+
+ +
+
+ {children} +
+
+
+
+
+
+ ); +}; interface CodeBlockProps { code?: string; diff --git a/apps/web/src/components/ArticleHeading.tsx b/apps/web/src/components/ArticleHeading.tsx index cdff17d..ff8438c 100644 --- a/apps/web/src/components/ArticleHeading.tsx +++ b/apps/web/src/components/ArticleHeading.tsx @@ -3,26 +3,52 @@ import React from "react"; interface HeadingProps { children: React.ReactNode; className?: string; + id?: string; } -export const H1: React.FC = ({ children, className = "" }) => ( +const getTextContent = (node: React.ReactNode): string => { + if (typeof node === "string") return node; + if (typeof node === "number") return String(node); + if (React.isValidElement(node)) return getTextContent((node.props as any).children); + if (Array.isArray(node)) return node.map(getTextContent).join(""); + return ""; +}; + +const charMap: Record = { "ä": "ae", "ö": "oe", "ü": "ue" }; + +const generateId = (children: React.ReactNode): string => { + const text = getTextContent(children); + return text + .toLowerCase() + .replace(/[äöü]/g, (c) => charMap[c] ?? c) + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +}; + +export const H1: React.FC = ({ children, className = "", id }) => (

{children}

); -export const H2: React.FC = ({ children, className = "" }) => ( -

- {children} -

-); +export const H2: React.FC = ({ children, className = "", id }) => { + const generatedId = id || generateId(children); + return ( +

+ {children} +

+ ); +}; -export const H3: React.FC = ({ children, className = "" }) => ( +export const H3: React.FC = ({ children, className = "", id }) => (

{children} diff --git a/apps/web/src/components/ArticleMeme.tsx b/apps/web/src/components/ArticleMeme.tsx index 2930eae..7f4fb4c 100644 --- a/apps/web/src/components/ArticleMeme.tsx +++ b/apps/web/src/components/ArticleMeme.tsx @@ -1,56 +1,120 @@ +"use client"; import React from 'react'; +import { ComponentShareButton } from './ComponentShareButton'; +import { Reveal } from './Reveal'; interface ArticleMemeProps { template: string; - captions: string[]; + /** Pipe-delimited captions, e.g. "top text|bottom text" */ + captions: string; className?: string; } -export const ArticleMeme: React.FC = ({ template, captions, className = '' }) => { +/** + * Encode a caption string for the memegen.link URL format. + */ +function encodeMemeCaption(text: string): string { + if (!text || text.trim() === '') return '_'; + + return text + .trim() + .replace(/_/g, "__") + .replace(/-/g, "--") + .replace(/\n/g, "~q") + .replace(/\?/g, "~q") + .replace(/&/g, "~a") + .replace(/%/g, "~p") + .replace(/#/g, "~h") + .replace(/\//g, "~s") + .replace(/\\/g, "~b") + .replace(/<(?:\/?[a-z0-9]+)*>/gi, "") + .replace(/\s+/g, "_") + .replace(/€/g, "Euro") // Replace Euro sign + .replace(/['"„“‚‘]/g, "") // Remove quotes + .replace(/[^a-zA-Z0-9_\-~äöüÄÖÜß.,!]/g, ""); // Remove other special chars +} + + +const TEMPLATE_MAP: Record = { + 'drake': 'drake', + 'drake hotline bling': 'drake', + 'distracted boyfriend': 'db', + 'distracted': 'db', + 'expanding brain': 'gb', + 'expanding': 'gb', + 'gb': 'gb', + 'this is fine': 'fine', + 'fine': 'fine', + 'clown': 'gb', + 'clown applying makeup': 'gb', + 'two buttons': 'ds', + 'daily struggle': 'ds', + 'ds': 'ds', + 'gru': 'gru', + 'change my mind': 'cmm', + 'always has been': 'ahb', + 'uno reverse': 'uno', + 'disaster girl': 'disastergirl', + 'is this a pigeon': 'pigeon', + 'roll safe': 'rollsafe', + 'rollsafe': 'rollsafe', + 'surprised pikachu': 'pikachu', + 'batman slapping robin': 'slap', + 'left exit 12': 'exit', + 'one does not simply': 'mordor', + 'panik kalm panik': 'panik-kalm-panik', +}; + +function slugifyTemplate(template: string): string { + if (!template) return 'drake'; + + const normalized = template.toLowerCase().trim(); + const validIds = new Set(Object.values(TEMPLATE_MAP)); + + // Check if it's already a valid known ID + if (validIds.has(normalized)) return normalized; + + // Check case-insensitive map + for (const key of Object.keys(TEMPLATE_MAP)) { + if (key === normalized) return TEMPLATE_MAP[key]; + } + + // Fallback: don't randomly slugify since memegen might 404. Force known good. + return 'drake'; +} + +function buildMemeUrl(template: string, captions: string[]): string { + const slug = slugifyTemplate(template); + const encoded = captions.map(encodeMemeCaption); + return `https://api.memegen.link/images/${slug}/${encoded.join('/')}.png`; +} + +export const ArticleMeme: React.FC = ({ template, captions, image, className = '' }) => { + const captionList = (captions || '').split('|').map(s => s.trim()).filter(Boolean); + const imageUrl = image || buildMemeUrl(template, captionList); + const shareId = `artmeme-${React.useId().replace(/:/g, "")}`; + return ( -
- {/* Meme "Image" Placeholder with Industrial Styling */} -
- {/* Decorative Grid */} -
+ +
+ {/* Ambient Glow */} +
- {/* Top Caption */} -
-

- {captions[0]} -

-
+
+ {/* Share Button */} +
+ +
- {/* Meme Figure/Avatar Placeholder */} -
- 🤖 -
- - {/* Bottom Caption */} -
-

- {captions[1] || ''} -

+ {captionList.length 0 ? captionList.join(' / ') : template} + className="w-full h-auto block" + loading="lazy" + />
- - {/* Meme Footer / Metadata */} -
-
-
- AI -
-
-

Meme Template

-

{template}

-
-
-
- -
-
-
+ ); }; diff --git a/apps/web/src/components/ArticleQuote.tsx b/apps/web/src/components/ArticleQuote.tsx index 70c4301..c79e539 100644 --- a/apps/web/src/components/ArticleQuote.tsx +++ b/apps/web/src/components/ArticleQuote.tsx @@ -1,4 +1,7 @@ import React from 'react'; +import { Quote } from 'lucide-react'; +import { ComponentShareButton } from './ComponentShareButton'; +import { Reveal } from './Reveal'; interface ArticleQuoteProps { quote: string; @@ -6,9 +9,7 @@ interface ArticleQuoteProps { role?: string; source?: string; sourceUrl?: string; - /** If true, shows a "Translated" badge */ translated?: boolean; - /** If true, treats the author as a company/brand (shows entity icon instead of initials) */ isCompany?: boolean; className?: string; } @@ -23,41 +24,65 @@ export const ArticleQuote: React.FC = ({ isCompany, className = '', }) => { + // Generate a stable ID based on author for sharing + const shareId = `quote-${author.toLowerCase().replace(/[^a-z0-9]/g, '-')}`; + return ( -
-
- {/* Meta column (left side on desktop) */} -
-
- {isCompany ? ( -
- -
- ) : ( -
- {(author || '').split(' ').map(w => w[0]).join('').slice(0, 2)} -
- )} -
- {author} - {role && {role}} - {translated && Translated} - {sourceUrl && ( - - {source || 'Source'} → - + +
+ {/* Ambient Background Glow matching the homepage feel */} +
+ +
+ + {/* Subtle top shine */} +
+ +
+ + {/* Share Button top right */} +
+ +
+ + {/* Author Meta (Left Rail) */} +
+ {isCompany ? ( +
+ +
+ ) : ( +
+ +
)} -
+
+ {translated && ( +
Übersetzt
+ )} + {sourceUrl ? ( + + {source || 'Source'} + + ) : ( + source && {source} + )} +
+
+ + {/* Quote Content (Right Rail) */} +
+ +
+ {quote} +
+
- - {/* Quote column (right side) */} -
-

- {quote} -

-
-
- + +
); }; diff --git a/apps/web/src/components/BoldNumber.tsx b/apps/web/src/components/BoldNumber.tsx new file mode 100644 index 0000000..122bcae --- /dev/null +++ b/apps/web/src/components/BoldNumber.tsx @@ -0,0 +1,137 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; + +interface BoldNumberProps { + /** The number to display, e.g. "53%" or "2.5M€" or "-20%" */ + value: string; + /** Short description of what this number means */ + label: string; + /** Source attribution */ + source?: string; + /** Source URL */ + sourceUrl?: string; + className?: string; +} + +/** + * Premium hero number component — full-width, dark gradient, animated count-up. + * Designed for shareable key statistics that stand out in blog posts. + */ +export const BoldNumber: React.FC = ({ + value, + label, + source, + sourceUrl, + className = '', +}) => { + const ref = useRef(null); + const [isVisible, setIsVisible] = useState(false); + const [displayValue, setDisplayValue] = useState(''); + + // Extract numeric part for animation + const numericMatch = value.match(/^([+-]?)(\d+(?:[.,]\d+)?)(.*)/); + const prefix = numericMatch?.[1] ?? ''; + const numStr = numericMatch?.[2] ?? ''; + const suffix = numericMatch?.[3] ?? value; + const targetNum = parseFloat(numStr.replace(',', '.')) || 0; + const hasDecimals = numStr.includes('.') || numStr.includes(','); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.disconnect(); + } + }, + { threshold: 0.3 } + ); + + observer.observe(el); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!isVisible || !numStr) { + setDisplayValue(value); + return; + } + + const duration = 1200; + const steps = 40; + const stepTime = duration / steps; + let step = 0; + + const timer = setInterval(() => { + step++; + const progress = Math.min(step / steps, 1); + // Ease out cubic + const eased = 1 - Math.pow(1 - progress, 3); + const current = targetNum * eased; + const formatted = hasDecimals ? current.toFixed(1) : Math.round(current).toString(); + setDisplayValue(`${prefix}${formatted}${suffix}`); + + if (step >= steps) { + clearInterval(timer); + setDisplayValue(value); + } + }, stepTime); + + return () => clearInterval(timer); + }, [isVisible, value, prefix, suffix, targetNum, hasDecimals, numStr]); + + const handleShare = async () => { + const shareText = `${value} — ${label}${source ? ` (${source})` : ''}`; + try { + if (navigator.share) { + await navigator.share({ text: shareText }); + } else { + await navigator.clipboard.writeText(shareText); + } + } catch { /* user cancelled */ } + }; + + return ( +
+
+ + {displayValue || value} + + + {label} + + {source && ( + + {sourceUrl ? ( + + Quelle: {source} ↗ + + ) : ( + `Quelle: ${source}` + )} + + )} +
+ + {/* Share button - subtle now */} + +
+ ); +}; diff --git a/apps/web/src/components/Carousel.tsx b/apps/web/src/components/Carousel.tsx new file mode 100644 index 0000000..8b56159 --- /dev/null +++ b/apps/web/src/components/Carousel.tsx @@ -0,0 +1,100 @@ +'use client'; + +import React, { useRef, useState } from 'react'; + +interface CarouselItem { + title: string; + content: string; + icon?: React.ReactNode; +} + +interface CarouselProps { + items: CarouselItem[]; + className?: string; +} + +export const Carousel: React.FC = ({ items, className = '' }) => { + const scrollRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + + const scrollTo = (index: number) => { + if (!scrollRef.current) return; + const width = scrollRef.current.clientWidth; + scrollRef.current.scrollTo({ left: index * width, behavior: 'smooth' }); + setActiveIndex(index); + }; + + const handleScroll = () => { + if (!scrollRef.current) return; + const width = scrollRef.current.clientWidth; + const index = Math.round(scrollRef.current.scrollLeft / width); + if (index !== activeIndex) setActiveIndex(index); + }; + + // Icons helper (default icon if none provided) + const DefaultIcon = () => ( + + + + ); + + return ( +
+
+ {/* Scroll Container */} +
+ {(items || []).map((item, index) => ( +
+
+ {item.icon || } +
+
+

{item.title}

+ {item.content && ( +

{item.content}

+ )} +
+
+ ))} +
+ + {/* Nav Buttons (Outside) */} + + +
+ + {/* Dots */} +
+ {(items || []).map((_, index) => ( +
+
+ ); +}; diff --git a/apps/web/src/components/ComponentShareButton.tsx b/apps/web/src/components/ComponentShareButton.tsx new file mode 100644 index 0000000..865245e --- /dev/null +++ b/apps/web/src/components/ComponentShareButton.tsx @@ -0,0 +1,95 @@ +"use client"; + +import React, { useState } from "react"; +import { Share2 } from "lucide-react"; +import { ShareModal } from "./ShareModal"; +import { useAnalytics } from "./analytics/useAnalytics"; +import * as htmlToImage from "html-to-image"; + +interface ComponentShareButtonProps { + targetId: string; + title?: string; + className?: string; +} + +export const ComponentShareButton: React.FC = ({ + targetId, + title = "Component", + className = "" +}) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [generatedImage, setGeneratedImage] = useState(); + const [isCapturing, setIsCapturing] = useState(false); + const { trackEvent } = useAnalytics(); + + const currentUrl = + typeof window !== "undefined" + ? `${window.location.origin}${window.location.pathname}#${targetId}` + : ""; + + const handleOpenModal = async () => { + setIsCapturing(true); + try { + const element = document.getElementById(targetId); + if (element) { + // Find existing share buttons and temporarily hide them during capture to avoid infinite recursion loops in screenshots + const shareButtons = element.querySelectorAll('[data-share-button="true"]'); + shareButtons.forEach(btn => (btn as HTMLElement).style.opacity = '0'); + + const dataUrl = await htmlToImage.toPng(element, { + quality: 1, + type: 'image/png', + pixelRatio: 2, + backgroundColor: '#ffffff', + skipFonts: true + }); + + // Restore buttons + shareButtons.forEach(btn => (btn as HTMLElement).style.opacity = '1'); + + setGeneratedImage(dataUrl); + } + } catch (err) { + console.error("Failed to capture component:", err); + } finally { + setIsCapturing(false); + setIsModalOpen(true); + trackEvent("component_share_opened", { + component_id: targetId, + component_title: title, + }); + } + }; + + return ( + <> + + + {/* ShareModal expects a direct image string in 'qrCodeData' or 'diagramImage' (except diagramImage specifically assumes SVGs). + Because ShareModal has logic that redraws diagramImage on a canvas assuming it's an SVG string, + we must bypass the SVG renderer. However, if we look at ShareModal, we need a way to pass a raw PNG. + Passing it as qrCodeData is a hack, or we can just send it via diagramImage and hope the canvas ignores it if it's already a Data URL. + Wait: ShareModal expects `diagramImage` (svg string) AND re-renders it. + Let's just pass our Data URL into a NEW prop or hijack the qrCodeData if necessary, but actually ShareModal only allows `diagramImage` as SVG logic right now. + Let's see if ShareModal needs an update to accept pure images, we'll check it. for now, let's pass it via diagramImage and see if we can adapt ShareModal. */} + + {/* We will adapt ShareModal to handle both SVG strings & base64 PNG inputs via `diagramImage` */} + setIsModalOpen(false)} + url={currentUrl} + title={title} + diagramImage={generatedImage} + /> + + ); +}; diff --git a/apps/web/src/components/ContactForm/components/PriceCalculation.tsx b/apps/web/src/components/ContactForm/components/PriceCalculation.tsx index 1dfe366..53202f3 100644 --- a/apps/web/src/components/ContactForm/components/PriceCalculation.tsx +++ b/apps/web/src/components/ContactForm/components/PriceCalculation.tsx @@ -9,8 +9,8 @@ import { ConceptAutomation } from "../../Landing/ConceptIllustrations"; import { Download, Share2, RefreshCw } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; // EstimationPDF will be imported dynamically where used or inside the and client-side block -import IconWhite from "../../../assets/logo/Icon White Transparent.png"; -import LogoBlack from "../../../assets/logo/Logo Black Transparent.png"; +import IconWhite from "../../../assets/logo/Icon-White-Transparent.png"; +import LogoBlack from "../../../assets/logo/Logo-Black-Transparent.png"; // PDF components removed from top-level dynamic import to fix ESM resolution issues in Next.js 16/Webpack diff --git a/apps/web/src/components/DiagramFlow.tsx b/apps/web/src/components/DiagramFlow.tsx new file mode 100644 index 0000000..7ef9376 --- /dev/null +++ b/apps/web/src/components/DiagramFlow.tsx @@ -0,0 +1,79 @@ +"use client"; + +import React from "react"; +import { Mermaid } from "./Mermaid"; + +interface FlowNode { + id: string; + label: string; + style?: string; +} + +interface FlowEdge { + from: string; + to: string; + label?: string; + type?: "solid" | "dotted"; +} + +interface DiagramFlowProps { + direction?: "LR" | "TB" | "RL" | "BT"; + nodes?: FlowNode[]; + edges?: FlowEdge[]; + title?: string; + caption?: string; + id?: string; + showShare?: boolean; + fontSize?: string; +} + +export const DiagramFlow: React.FC = ({ + direction = "LR", + nodes = [], + edges = [], + title, + caption, + id, + showShare = true, + fontSize = "16px", +}) => { + const lines: string[] = [`graph ${direction}`]; + + // Declare nodes with labels + for (const node of nodes) { + lines.push(` ${node.id}[${JSON.stringify(node.label)}]`); + } + + // Add edges + for (const edge of edges) { + const arrow = edge.type === "dotted" ? "-.-" : "-->"; + const label = edge.label ? `|${edge.label}|` : ""; + lines.push(` ${edge.from} ${arrow} ${label}${edge.to}`); + } + + // Add styles + for (const node of nodes) { + if (node.style) { + lines.push(` style ${node.id} ${node.style}`); + } + } + + const flowGraph = lines.join("\n"); + + return ( +
+ + {caption && ( +
+ {caption} +
+ )} +
+ ); +}; diff --git a/apps/web/src/components/DiagramSequence.tsx b/apps/web/src/components/DiagramSequence.tsx index b9afb6b..6b22f64 100644 --- a/apps/web/src/components/DiagramSequence.tsx +++ b/apps/web/src/components/DiagramSequence.tsx @@ -3,6 +3,11 @@ import React from "react"; import { Mermaid } from "./Mermaid"; +interface SequenceParticipant { + id: string; + label?: string; +} + interface SequenceMessage { from: string; to: string; @@ -10,9 +15,22 @@ interface SequenceMessage { type?: "solid" | "dotted" | "async"; } +interface SequenceNote { + over: string | string[]; + text: string; +} + +type SequenceStep = SequenceMessage | SequenceNote; + +function isNote(step: SequenceStep): step is SequenceNote { + return "over" in step; +} + interface DiagramSequenceProps { - participants: string[]; - messages: SequenceMessage[]; + participants: (string | SequenceParticipant)[]; + steps?: SequenceStep[]; + messages?: SequenceMessage[]; + children?: React.ReactNode; title?: string; caption?: string; id?: string; @@ -22,7 +40,9 @@ interface DiagramSequenceProps { export const DiagramSequence: React.FC = ({ participants, + steps, messages, + children, title, caption, id, @@ -32,17 +52,38 @@ export const DiagramSequence: React.FC = ({ const getArrow = (type?: string) => { switch (type) { case "dotted": - return "-->"; + return "-->>"; case "async": return "->>"; default: - return "->"; + return "->>"; } }; - const sequenceGraph = `sequenceDiagram -${(participants || []).map((p) => ` participant ${p}`).join("\n")} -${(messages || []).map((m) => ` ${m.from}${getArrow(m.type)}${m.to}: ${m.message}`).join("\n")}`; + const participantLines = (participants || []).map((p) => { + if (typeof p === "string") return ` participant ${p}`; + return p.label + ? ` participant ${p.id} as ${p.label}` + : ` participant ${p.id}`; + }); + + // Support both `steps` (mixed messages + notes) and legacy `messages` + const allSteps = steps || (messages || []); + + const stepLines = allSteps.map((step) => { + if (isNote(step)) { + const over = Array.isArray(step.over) ? step.over.join(",") : step.over; + return ` Note over ${over}: ${step.text}`; + } + return ` ${step.from}${getArrow(step.type)}${step.to}: ${step.message}`; + }); + + const participantLinesSection = participantLines.length > 0 ? `${participantLines.join("\n")}\n` : ""; + const generatedStepsSection = stepLines.length > 0 ? stepLines.join("\n") : ""; + + const sequenceGraph = children + ? (typeof children === "string" ? children : "") + : `sequenceDiagram\n${participantLinesSection}${generatedStepsSection}`; return (
@@ -61,3 +102,4 @@ ${(messages || []).map((m) => ` ${m.from}${getArrow(m.type)}${m.to}: ${m.mess
); }; + diff --git a/apps/web/src/components/ExternalLink.tsx b/apps/web/src/components/ExternalLink.tsx new file mode 100644 index 0000000..a3f2535 --- /dev/null +++ b/apps/web/src/components/ExternalLink.tsx @@ -0,0 +1,45 @@ +'use client'; + +import React from 'react'; +import { track } from '../utils/analytics'; + +interface ExternalLinkProps { + href: string; + children: React.ReactNode; + className?: string; +} + +export const ExternalLink: React.FC = ({ href, children, className = '' }) => { + const handleClick = () => { + const text = typeof children === 'string' ? children : href; + track('Outbound Link', { url: href, text }); + }; + + return ( + + {children} + + + ); +}; diff --git a/apps/web/src/components/FAQSection.tsx b/apps/web/src/components/FAQSection.tsx new file mode 100644 index 0000000..3cc80d8 --- /dev/null +++ b/apps/web/src/components/FAQSection.tsx @@ -0,0 +1,29 @@ +"use client"; + +import React from "react"; +import { H3 } from "./ArticleHeading"; +import { Paragraph } from "./ArticleParagraph"; + +interface FAQItem { + question: string; + answer: string; +} + +interface FAQSectionProps { + children?: React.ReactNode; +} + +/** + * FAQSection: A simple semantic wrapper for FAQs in blog posts. + * It can be used by the AI to wrap a list of questions and answers. + */ +export const FAQSection: React.FC = ({ children }) => { + return ( +
+

Häufig gestellte Fragen (FAQ)

+
+ {children} +
+
+ ); +}; diff --git a/apps/web/src/components/Footer.tsx b/apps/web/src/components/Footer.tsx index 0bcba2a..976e54b 100644 --- a/apps/web/src/components/Footer.tsx +++ b/apps/web/src/components/Footer.tsx @@ -1,7 +1,7 @@ import Image from "next/image"; import * as React from "react"; -import LogoBlack from "../assets/logo/Logo Black Transparent.svg"; +import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg"; export const Footer: React.FC = () => { const currentYear = new Date().getFullYear(); diff --git a/apps/web/src/components/Header.tsx b/apps/web/src/components/Header.tsx index c013a24..37c9195 100644 --- a/apps/web/src/components/Header.tsx +++ b/apps/web/src/components/Header.tsx @@ -2,13 +2,13 @@ import { AnimatePresence, motion } from "framer-motion"; import Image from "next/image"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { useSafePathname } from "./analytics/useSafePathname"; import * as React from "react"; -import IconWhite from "../assets/logo/Icon White Transparent.svg"; +import IconWhite from "../assets/logo/Icon-White-Transparent.svg"; export const Header: React.FC = () => { - const pathname = usePathname(); + const pathname = useSafePathname(); const [isScrolled, setIsScrolled] = React.useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); @@ -49,11 +49,10 @@ export const Header: React.FC = () => {
{/* Decoupled Background Layer - Prevents backdrop-filter parent context bugs */}
{/* Animated tech border at bottom */} @@ -95,11 +94,10 @@ export const Header: React.FC = () => { {active && ( @@ -247,11 +245,10 @@ export const Header: React.FC = () => { setIsMobileMenuOpen(false)} - className={`relative flex flex-col justify-center p-6 h-[110px] rounded-2xl border transition-all duration-200 ${ - active + className={`relative flex flex-col justify-center p-6 h-[110px] rounded-2xl border transition-all duration-200 ${active ? "bg-slate-50 border-slate-200 ring-1 ring-slate-200" : "bg-white border-slate-100 active:bg-slate-50" - }`} + }`} >
diff --git a/apps/web/src/components/Header.tsx.bak b/apps/web/src/components/Header.tsx.bak new file mode 100644 index 0000000..c013a24 --- /dev/null +++ b/apps/web/src/components/Header.tsx.bak @@ -0,0 +1,315 @@ +"use client"; +import { AnimatePresence, motion } from "framer-motion"; +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import * as React from "react"; + +import IconWhite from "../assets/logo/Icon White Transparent.svg"; + +export const Header: React.FC = () => { + const pathname = usePathname(); + const [isScrolled, setIsScrolled] = React.useState(false); + const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); + + React.useEffect(() => { + const handleScroll = () => { + setIsScrolled(window.scrollY > 20); + }; + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + // Close mobile menu on pathname change and handle body scroll lock + React.useEffect(() => { + setIsMobileMenuOpen(false); + }, [pathname]); + + React.useEffect(() => { + if (isMobileMenuOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = "unset"; + } + return () => { + document.body.style.overflow = "unset"; + }; + }, [isMobileMenuOpen]); + + const isActive = (path: string) => pathname === path; + + const navLinks = [ + { href: "/about", label: "Über mich" }, + { href: "/websites", label: "Websites" }, + { href: "/case-studies", label: "Case Studies", prefix: true }, + { href: "/blog", label: "Blog", prefix: true }, + ]; + + return ( +
+ {/* Decoupled Background Layer - Prevents backdrop-filter parent context bugs */} +
+ + {/* Animated tech border at bottom */} +
+
+
+ +
+ +
+
+ Marc Mintel Icon +
+
+ + + {/* Desktop Navigation */} + + + {/* Mobile Toggle */} + +
+ + {/* Mobile Navigation - Bottom-Anchored Control Center */} + + {isMobileMenuOpen && ( + + {/* Dimmed Backdrop */} + setIsMobileMenuOpen(false)} + className="fixed inset-0 z-[101] bg-black/30 backdrop-blur-sm md:hidden" + /> + + {/* Bottom Sheet */} + { + if (info.offset.y > 80 || info.velocity.y > 300) { + setIsMobileMenuOpen(false); + } + }} + className="fixed inset-x-0 bottom-0 z-[102] md:hidden bg-white rounded-t-[2rem] shadow-[0_-8px_40px_rgba(0,0,0,0.12)] flex flex-col max-h-[85vh] overflow-hidden" + > + {/* Grab Handle */} +
+
+
+ + {/* Status Bar */} +
+
+
+ Online +
+
+ {pathname === "/" + ? "HOME" + : pathname.toUpperCase().replace(/^\//, "")} +
+
+ + {/* Tiled Navigation Grid */} +
+
+ {[ + { href: "/about", label: "Über mich", sub: "Architect" }, + { href: "/websites", label: "Websites", sub: "Systems" }, + { + href: "/case-studies", + label: "Cases", + sub: "Solutions", + prefix: true, + }, + { + href: "/blog", + label: "Blog", + sub: "Insights", + prefix: true, + }, + ].map((item, i) => { + const active = item.prefix + ? pathname?.startsWith(item.href) + : pathname === item.href; + + return ( + + setIsMobileMenuOpen(false)} + className={`relative flex flex-col justify-center p-6 h-[110px] rounded-2xl border transition-all duration-200 ${ + active + ? "bg-slate-50 border-slate-200 ring-1 ring-slate-200" + : "bg-white border-slate-100 active:bg-slate-50" + }`} + > +
+ + {item.label} + + + {item.sub} + +
+ {active && ( +
+ )} + + + ); + })} +
+
+ + {/* Primary CTA */} +
+ + setIsMobileMenuOpen(false)} + className="flex items-center justify-between w-full p-4 bg-slate-900 text-white rounded-2xl active:bg-slate-800 transition-colors" + > + + Projekt anfragen + + + + + + +
+ + {/* Safe-Area Spacer (iOS home indicator) */} +
+ + + )} + +
+ ); +}; diff --git a/apps/web/src/components/IconList.tsx b/apps/web/src/components/IconList.tsx index 9045cc8..a166b72 100644 --- a/apps/web/src/components/IconList.tsx +++ b/apps/web/src/components/IconList.tsx @@ -1,9 +1,13 @@ import React from "react"; -import { Check } from "lucide-react"; +import { Check, X } from "lucide-react"; +import { ComponentShareButton } from "./ComponentShareButton"; +import { Reveal } from "./Reveal"; interface IconListProps { children: React.ReactNode; + title?: string; className?: string; + showShare?: boolean; } interface IconListItemProps { @@ -11,6 +15,7 @@ interface IconListItemProps { icon?: React.ReactNode; bullet?: boolean; check?: boolean; + cross?: boolean; className?: string; iconClassName?: string; iconContainerClassName?: string; @@ -18,14 +23,54 @@ interface IconListItemProps { export const IconList: React.FC = ({ children, + title, className = "", -}) =>
    {children}
; + showShare = false, +}) => { + const shareId = `iconlist-${React.useId().replace(/:/g, "")}`; + + return ( + +
+ {/* Ambient Glow */} +
+ +
+ + {/* Subtle top shine */} +
+ + {/* Share Button top right */} + {showShare && ( +
+ +
+ )} + + {title && ( +

+ {title} +

+ )} + +
    + {children} +
+
+
+
+ ); +}; export const IconListItem: React.FC = ({ children, icon, bullet, check, + cross, className = "", iconClassName = "", iconContainerClassName = "", @@ -34,26 +79,32 @@ export const IconListItem: React.FC = ({ if (bullet) { renderIcon = ( -
+
); } else if (check) { renderIcon = ( -
- +
+ +
+ ); + } else if (check === false || cross) { + renderIcon = ( +
+
); } return ( -
  • +
  • {renderIcon && (
    {renderIcon}
    )} -
    {children}
    +
    {children}
  • ); }; diff --git a/apps/web/src/components/ImageText.tsx b/apps/web/src/components/ImageText.tsx new file mode 100644 index 0000000..6e93e9d --- /dev/null +++ b/apps/web/src/components/ImageText.tsx @@ -0,0 +1,42 @@ +'use client'; + +import React from 'react'; + +interface ImageTextProps { + image: string; + title: string; + children: React.ReactNode; + reversed?: boolean; + className?: string; +} + +export const ImageText: React.FC = ({ + image, + title, + children, + reversed = false, + className = '', +}) => { + return ( +
    + {/* Image Side */} +
    +
    + {title} +
    + + {/* Text Side */} +
    +

    {title}

    +
    + {children} +
    +
    +
    + ); +}; diff --git a/apps/web/src/components/Landing/ComparisonRow.tsx b/apps/web/src/components/Landing/ComparisonRow.tsx index e781515..4e82952 100644 --- a/apps/web/src/components/Landing/ComparisonRow.tsx +++ b/apps/web/src/components/Landing/ComparisonRow.tsx @@ -1,32 +1,110 @@ import * as React from "react"; -import { ArrowRight } from "lucide-react"; +import { ArrowRight, Check, X } from "lucide-react"; import { Reveal } from "../Reveal"; import { Label, H3, LeadText } from "../Typography"; import { Strikethrough } from "../Strikethrough"; import { cn } from "../../utils/cn"; +import { ComponentShareButton } from "../ComponentShareButton"; interface ComparisonRowProps { description?: string; - negativeLabel: string; - negativeText: string; - positiveLabel: string; - positiveText: React.ReactNode; + // Legacy props + negativeLabel?: string; + negativeText?: string; + positiveLabel?: string; + positiveText?: React.ReactNode; + + // New props for lists + leftTitle?: string; + leftItems?: string[]; + rightTitle?: string; + rightItems?: string[]; + reverse?: boolean; delay?: number; + showShare?: boolean; } export const ComparisonRow: React.FC = ({ description, + negativeLabel, negativeText, positiveLabel, positiveText, + + leftTitle, + leftItems, + rightTitle, + rightItems, + reverse = false, delay = 0, + showShare = false, }) => { + const shareId = `comprow-${React.useId().replace(/:/g, "")}`; + // Normalize inputs + const labelLeft = leftTitle || negativeLabel; + const contentLeft = leftItems || negativeText; + + const labelRight = rightTitle || positiveLabel; + const contentRight = rightItems || positiveText; + + // Helper to render left side content (Strikethrough) + const renderLeft = () => { + if (Array.isArray(contentLeft)) { + return ( +
      + {contentLeft.map((item, i) => ( +
    • + + + {item} + +
    • + ))} +
    + ); + } + if (typeof contentLeft === "string") { + return ( + + {contentLeft} + + ); + } + return null; + }; + + // Helper to render right side content (Positive) + const renderRight = () => { + if (Array.isArray(contentRight)) { + return ( +
      + {contentRight.map((item, i) => ( +
    • + + + {item} + +
    • + ))} +
    + ); + } + // Default / Legacy positiveText (usually a Node or string) + return

    {contentRight}

    ; + }; + return ( -
    +
    + {showShare && ( +
    + +
    + )} + {description && (