From 8d7f5ac2e993e2fda94617ba6343836f28bf60a4 Mon Sep 17 00:00:00 2001 From: Joshua <62268199+minimalsm@users.noreply.github.com> Date: Fri, 23 Jan 2026 05:19:45 +0000 Subject: [PATCH 01/10] fix(tutorials): use filesystem reads with English fallback Refactor getTutorialsData to read tutorial content from the filesystem instead of making HTTP requests to the production site during build. Changes: - Switch from fetch() to fs.readFile() for tutorial content - Add English fallback when translations don't exist - Add isTranslated field to ITutorial interface - Remove unused SITE_URL import This eliminates ~5000 "Failed to fetch tutorial" 404 warnings during builds for locales without tutorial translations. Instead of silently dropping tutorials, non-English locales now display English content with isTranslated: false flag for potential UI indicators. Closes #17150 --- src/lib/types.ts | 1 + src/lib/utils/md.ts | 71 +++++++++++++++++++++++++++------------------ 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index c4cb305501a..5d93f743b04 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1170,6 +1170,7 @@ export interface ITutorial { published?: string | null lang: string isExternal: boolean + isTranslated?: boolean } export enum AppCategoryEnum { diff --git a/src/lib/utils/md.ts b/src/lib/utils/md.ts index 331604085b5..41f5fcb4488 100644 --- a/src/lib/utils/md.ts +++ b/src/lib/utils/md.ts @@ -10,7 +10,7 @@ import { dateToString } from "@/lib/utils/date" import internalTutorialSlugs from "@/data/internalTutorials.json" -import { CONTENT_DIR, SITE_URL } from "@/lib/constants" +import { CONTENT_DIR } from "@/lib/constants" import { toPosixPath } from "./relativePath" @@ -75,28 +75,50 @@ export const getPostSlugs = async (dir: string, filterRegex?: RegExp) => { export const getTutorialsData = async ( locale: string ): Promise => { - const tutorialData: ITutorial[] = [] + const contentRoot = getContentRoot() - // Fetch tutorials from public URLs in parallel + // Read tutorials from filesystem in parallel const tutorialPromises = (internalTutorialSlugs as string[]).map( async (slug) => { try { - const path = - locale !== "en" - ? `/content/translations/${locale}/developers/tutorials/${slug}/index.md` - : `/content/developers/tutorials/${slug}/index.md` - - const url = new URL(path, SITE_URL).toString() - - const response = await fetch(url) - if (!response.ok) { - console.warn( - `Failed to fetch tutorial ${slug} for locale ${locale}: ${response.status}` + let fileContents: string + let isTranslated = true + + if (locale === "en") { + // English: read directly from content directory + const englishPath = join( + contentRoot, + "developers/tutorials", + slug, + "index.md" + ) + fileContents = await fsp.readFile(englishPath, "utf-8") + } else { + // Non-English: try translation first, fallback to English + const translatedPath = join( + contentRoot, + "translations", + locale, + "developers/tutorials", + slug, + "index.md" ) - return null + + try { + fileContents = await fsp.readFile(translatedPath, "utf-8") + } catch { + // Fallback to English content + const englishPath = join( + contentRoot, + "developers/tutorials", + slug, + "index.md" + ) + fileContents = await fsp.readFile(englishPath, "utf-8") + isTranslated = false + } } - const fileContents = await response.text() const { data, content } = matter(fileContents) const frontmatter = data as Frontmatter @@ -111,12 +133,11 @@ export const getTutorialsData = async ( published: dateToString(frontmatter.published), lang: frontmatter.lang, isExternal: false, + isTranslated, } } catch (error) { - console.warn( - `Error fetching tutorial ${slug} for locale ${locale}:`, - error - ) + // Only warn if English content is missing (actual error) + console.warn(`Error reading tutorial ${slug}:`, error) return null } } @@ -124,14 +145,8 @@ export const getTutorialsData = async ( const results = await Promise.all(tutorialPromises) - // Filter out null results (failed fetches) - results.forEach((tutorial) => { - if (tutorial) { - tutorialData.push(tutorial) - } - }) - - return tutorialData + // Filter out null results (missing tutorials) + return results.filter((tutorial) => tutorial !== null) as ITutorial[] } export const checkPathValidity = ( From 737337e677380c92ab9556daf9704fc7a80db08f Mon Sep 17 00:00:00 2001 From: Joshua <62268199+minimalsm@users.noreply.github.com> Date: Fri, 23 Jan 2026 05:22:57 +0000 Subject: [PATCH 02/10] refactor: use dynamic imports to match importMd pattern --- src/lib/utils/md.ts | 43 ++++++++++++++++--------------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/src/lib/utils/md.ts b/src/lib/utils/md.ts index 41f5fcb4488..02eef9bce4d 100644 --- a/src/lib/utils/md.ts +++ b/src/lib/utils/md.ts @@ -75,9 +75,7 @@ export const getPostSlugs = async (dir: string, filterRegex?: RegExp) => { export const getTutorialsData = async ( locale: string ): Promise => { - const contentRoot = getContentRoot() - - // Read tutorials from filesystem in parallel + // Read tutorials from filesystem in parallel using dynamic imports const tutorialPromises = (internalTutorialSlugs as string[]).map( async (slug) => { try { @@ -86,35 +84,26 @@ export const getTutorialsData = async ( if (locale === "en") { // English: read directly from content directory - const englishPath = join( - contentRoot, - "developers/tutorials", - slug, - "index.md" - ) - fileContents = await fsp.readFile(englishPath, "utf-8") + fileContents = ( + await import( + `../../../public/content/developers/tutorials/${slug}/index.md` + ) + ).default } else { // Non-English: try translation first, fallback to English - const translatedPath = join( - contentRoot, - "translations", - locale, - "developers/tutorials", - slug, - "index.md" - ) - try { - fileContents = await fsp.readFile(translatedPath, "utf-8") + fileContents = ( + await import( + `../../../public/content/translations/${locale}/developers/tutorials/${slug}/index.md` + ) + ).default } catch { // Fallback to English content - const englishPath = join( - contentRoot, - "developers/tutorials", - slug, - "index.md" - ) - fileContents = await fsp.readFile(englishPath, "utf-8") + fileContents = ( + await import( + `../../../public/content/developers/tutorials/${slug}/index.md` + ) + ).default isTranslated = false } } From 794ddb1358c399face16dcf8ed10858b554087af Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 23 Jan 2026 16:31:50 +0000 Subject: [PATCH 03/10] Update chains data --- src/data/chains.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/data/chains.ts b/src/data/chains.ts index 3e727ffae94..9cf11231161 100644 --- a/src/data/chains.ts +++ b/src/data/chains.ts @@ -1000,6 +1000,17 @@ const chains = [ }, chain: "Muster", }, + { + name: "RISE", + infoURL: "https://risechain.com", + chainId: 4153, + nativeCurrency: { + name: "Ether", + symbol: "ETH", + decimals: 18, + }, + chain: "ETH", + }, { name: "MegaETH Mainnet", infoURL: "https://megaeth.com", From 72e6c7d7577766ba2273266987ea78baed624754 Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Mon, 26 Jan 2026 12:46:02 +0100 Subject: [PATCH 04/10] Add retry logic to scheduled task fetchers Wrap all fetcher calls in runTasks with retry.onThrow to handle transient failures like CoinGecko 429 rate limits. Uses exponential backoff (2s-30s) with up to 3 attempts and jitter to avoid thundering herd issues. --- src/data-layer/tasks.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/data-layer/tasks.ts b/src/data-layer/tasks.ts index 19863382013..5d825d6a475 100644 --- a/src/data-layer/tasks.ts +++ b/src/data-layer/tasks.ts @@ -5,7 +5,7 @@ * Hourly tasks run every hour. */ -import { schedules } from "@trigger.dev/sdk/v3" +import { retry, schedules } from "@trigger.dev/sdk/v3" import { fetchApps } from "./fetchers/fetchApps" import { fetchBeaconChain } from "./fetchers/fetchBeaconChain" @@ -86,8 +86,14 @@ const HOURLY: Task[] = [ async function runTasks(tasks: Task[]) { const results = await Promise.allSettled( - tasks.map(async ([key, fetch]) => { - const data = await fetch() + tasks.map(async ([key, fetchFn]) => { + const data = await retry.onThrow(fetchFn, { + maxAttempts: 3, + minTimeoutInMs: 2000, + maxTimeoutInMs: 30000, + factor: 2, + randomize: true, + }) await set(key, data) console.log(`✓ ${key}`) return key From d7304a5731a2225385b474daaada676600434638 Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Mon, 26 Jan 2026 13:29:30 +0100 Subject: [PATCH 05/10] chore(storybook): replace fa locale with ar for RTL testing The fa (Farsi) locale has been removed from the project. Replace it with ar (Arabic) which is still actively supported and also provides RTL layout testing capabilities. --- .storybook/modes.ts | 2 +- .storybook/next-intl.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.storybook/modes.ts b/.storybook/modes.ts index e80df9d115d..22c5914b36a 100644 --- a/.storybook/modes.ts +++ b/.storybook/modes.ts @@ -14,7 +14,7 @@ export const viewportModes = breakpointSet.reduce<{ } }, {}) -const localesToTest = ["en", "fa"] +const localesToTest = ["en", "ar"] const locales = pickBy(baseLocales, (_, key) => localesToTest.includes(key)) export const langModes = Object.keys(locales).reduce<{ [locale: string]: { locale: string } diff --git a/.storybook/next-intl.ts b/.storybook/next-intl.ts index 7e793fde433..7c78b768786 100644 --- a/.storybook/next-intl.ts +++ b/.storybook/next-intl.ts @@ -3,7 +3,7 @@ export const baseLocales = { zh: { title: "中国人", left: "Zh" }, ru: { title: "Русский", left: "Ru" }, uk: { title: "українська", left: "Uk" }, - fa: { title: "فارسی", left: "Fa" }, + ar: { title: "العربية", left: "Ar" }, } // Only i18n files named in this array are being exposed to Storybook. Add filenames as necessary. From efb0d76dbea79a3666a62d2af17cfd0594890f12 Mon Sep 17 00:00:00 2001 From: Corwin Smith Date: Tue, 27 Jan 2026 15:08:14 -0700 Subject: [PATCH 06/10] Remove @corwintines from default CODEOWNERS --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9afbc926c06..f6c945e5f27 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,8 +5,8 @@ # These owners will be the default owners for everything in # the repo. Unless a later match takes precedence, -* @wackerow @corwintines @pettinarip @minimalsm +* @wackerow @pettinarip @minimalsm # Owners of specific files /src/data/consensus-bounty-hunters.json @asanso @fredriksvantes -/src/data/wallets/new-to-crypto.ts @konopkja @minimalsm \ No newline at end of file +/src/data/wallets/new-to-crypto.ts @konopkja @minimalsm From 3001c3db2e83f3f9008d899592f16577a9633afe Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Wed, 28 Jan 2026 14:11:45 +0100 Subject: [PATCH 07/10] Use React portal for ABTestDebugPanel Render the debug panel to document.body via createPortal to avoid z-index and styling conflicts when nested inside other components. --- src/components/AB/TestDebugPanel.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/AB/TestDebugPanel.tsx b/src/components/AB/TestDebugPanel.tsx index bc5abba23e4..257849295a8 100644 --- a/src/components/AB/TestDebugPanel.tsx +++ b/src/components/AB/TestDebugPanel.tsx @@ -1,6 +1,7 @@ "use client" -import { useRef, useState } from "react" +import { useEffect, useRef, useState } from "react" +import { createPortal } from "react-dom" import { cn } from "@/lib/utils/cn" @@ -19,6 +20,7 @@ export const ABTestDebugPanel = ({ availableVariants, }: ABTestDebugPanelProps) => { const [isOpen, setIsOpen] = useState(false) + const [mounted, setMounted] = useState(false) const [selectedVariant, setSelectedVariant] = useLocalStorage( `ab-test-${testKey}`, null @@ -27,10 +29,14 @@ export const ABTestDebugPanel = ({ useOnClickOutside(panelRef, () => setIsOpen(false)) + useEffect(() => { + setMounted(true) + }, []) + const forceVariant = (variantIndex: number) => setSelectedVariant(variantIndex) - return ( + const panelContent = (
) + + // Only render portal on client side after mount + if (!mounted) return null + + return createPortal(panelContent, document.body) } From 8eb43cea7f7f4cece39ab3e675fd6d8d2cb51b86 Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Wed, 28 Jan 2026 14:25:43 +0100 Subject: [PATCH 08/10] refactor: use DEFAULT_LOCALE constant instead of hardcoded "en" Improves consistency with the existing importMd pattern. --- src/lib/utils/md.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/utils/md.ts b/src/lib/utils/md.ts index 02eef9bce4d..a65bd9ca48b 100644 --- a/src/lib/utils/md.ts +++ b/src/lib/utils/md.ts @@ -10,7 +10,7 @@ import { dateToString } from "@/lib/utils/date" import internalTutorialSlugs from "@/data/internalTutorials.json" -import { CONTENT_DIR } from "@/lib/constants" +import { CONTENT_DIR, DEFAULT_LOCALE } from "@/lib/constants" import { toPosixPath } from "./relativePath" @@ -82,7 +82,7 @@ export const getTutorialsData = async ( let fileContents: string let isTranslated = true - if (locale === "en") { + if (locale === DEFAULT_LOCALE) { // English: read directly from content directory fileContents = ( await import( From fdb69e13944048ddf40b5ef4709d910491f49e8e Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Wed, 28 Jan 2026 16:50:07 +0100 Subject: [PATCH 09/10] add homepage persona modal A/B test variant Introduces a new A/B test variation for the homepage that replaces the four CTA grid with a single "Start here" button that opens a persona selection modal. The modal lets users self-identify as beginners, developers, or enterprise and routes them to relevant pages. --- app/[locale]/page.tsx | 94 ++++++----- src/components/Hero/HomeHero/index.tsx | 46 +++++- src/components/Homepage/PersonaModalCTA.tsx | 173 ++++++++++++++++++++ 3 files changed, 268 insertions(+), 45 deletions(-) create mode 100644 src/components/Homepage/PersonaModalCTA.tsx diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index b6e1316a797..7fa00381dfd 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -12,12 +12,14 @@ import type { } from "@/lib/types" import { CodeExample } from "@/lib/interfaces" +import ABTestWrapper from "@/components/AB/TestWrapper" import ActivityStats from "@/components/ActivityStats" import { ChevronNext } from "@/components/Chevron" import HomeHero from "@/components/Hero/HomeHero" import BentoCard from "@/components/Homepage/BentoCard" import CodeExamples from "@/components/Homepage/CodeExamples" import HomepageSectionImage from "@/components/Homepage/HomepageSectionImage" +import PersonaModalCTA from "@/components/Homepage/PersonaModalCTA" import { getBentoBoxItems } from "@/components/Homepage/utils" import ValuesMarqueeFallback from "@/components/Homepage/ValuesMarquee/Fallback" import BlockHeap from "@/components/icons/block-heap.svg" @@ -450,44 +452,60 @@ const Page = async ({ params }: { params: PageParams }) => {
-
- {subHeroCTAs.map( - ({ label, description, href, className, Svg }, idx) => { - const Link = ( - props: Omit< - SvgButtonLinkProps, - "Svg" | "href" | "label" | "children" - > - ) => ( - -

{description}

-
- ) - return ( - - - - - ) - } - )} -
+ + {subHeroCTAs.map( + ({ label, description, href, className, Svg }, idx) => { + const Link = ( + props: Omit< + SvgButtonLinkProps, + "Svg" | "href" | "label" | "children" + > + ) => ( + +

{description}

+
+ ) + return ( + + + + + ) + } + )} +
, + // Variation1: "Start here" button with persona modal +
+ +
, + ]} + /> {/* What is Ethereum */}
- -
-

{t("page-index-title")}

-

- {t("page-index-description")} -

-
+ + +
+

{t("page-index-title")}

+

+ {t("page-index-description")} +

+
+
, + // Variation1: New title/subtitle for persona modal +
+ +
+

+ The internet +
+ that belongs to you +

+

+ Create, own, build, connect, and transact. +
+ Ethereum is a network that everyone can use and anyone can + build on. +

+
+
, + ]} + /> ) diff --git a/src/components/Homepage/PersonaModalCTA.tsx b/src/components/Homepage/PersonaModalCTA.tsx new file mode 100644 index 00000000000..aedc5b6a994 --- /dev/null +++ b/src/components/Homepage/PersonaModalCTA.tsx @@ -0,0 +1,173 @@ +"use client" + +import { useState } from "react" +import { BookOpen, Building2, Code, ExternalLink } from "lucide-react" + +import { ChevronNext } from "@/components/Chevron" +import { Button } from "@/components/ui/buttons/Button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog-modal" +import { BaseLink } from "@/components/ui/Link" + +import { cn } from "@/lib/utils/cn" +import { trackCustomEvent } from "@/lib/utils/matomo" + +type PersonaLink = { + label: string + href: string + isExternal?: boolean +} + +type PersonaCategory = { + id: string + label: string + Icon: React.FC<{ className?: string }> + iconBgClass: string + iconColorClass: string + links: PersonaLink[] +} + +const categories: PersonaCategory[] = [ + { + id: "beginners", + label: "For beginners", + Icon: BookOpen, + iconBgClass: "bg-accent-a/20", + iconColorClass: "text-accent-a", + links: [ + { label: "What is Ethereum?", href: "/what-is-ethereum/" }, + { label: "Get a wallet", href: "/wallets/find-wallet/" }, + ], + }, + { + id: "developers", + label: "For developers", + Icon: Code, + iconBgClass: "bg-primary-low-contrast", + iconColorClass: "text-primary", + links: [ + { label: "Developer Hub", href: "/developers/" }, + { label: "Docs", href: "/developers/docs/" }, + ], + }, + { + id: "enterprise", + label: "For enterprise", + Icon: Building2, + iconBgClass: "bg-accent-c/20", + iconColorClass: "text-accent-c", + links: [ + { label: "Founders", href: "/founders/" }, + { + label: "Institutions", + href: "https://institutions.ethereum.org/", + isExternal: true, + }, + ], + }, +] + +type PersonaModalCTAProps = { + eventCategory: string +} + +const PersonaModalCTA = ({ eventCategory }: PersonaModalCTAProps) => { + const [isOpen, setIsOpen] = useState(false) + + const handleOpenChange = (open: boolean) => { + if (open) { + trackCustomEvent({ + eventCategory, + eventAction: "start here", + eventName: "start here", + }) + } + setIsOpen(open) + } + + const handleLinkClick = (label: string) => { + trackCustomEvent({ + eventCategory, + eventAction: "modal", + eventName: label, + }) + setIsOpen(false) + } + + return ( + + + + + + + + What brings you here? + + +
+ {categories.map( + ({ id, label, Icon, iconBgClass, iconColorClass, links }) => ( +
+ {/* Icon and Category Label */} +
+
+ +
+

+ {label} +

+
+ + {/* Links */} +
+ {links.map(({ label: linkLabel, href, isExternal }, idx) => ( +
+ {idx > 0 &&
} + handleLinkClick(linkLabel)} + hideArrow + className="group flex items-center justify-between text-xl font-bold text-primary no-underline transition-colors hover:text-primary-hover md:text-3xl" + {...(isExternal && { + target: "_blank", + rel: "noopener noreferrer", + })} + > + + {linkLabel} + {isExternal && ( + + )} + + + +
+ ))} +
+
+ ) + )} +
+ +
+ ) +} + +export default PersonaModalCTA From f00b3abef61adb117f9e54b5a6c0dbfe178ebf3e Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Thu, 29 Jan 2026 09:03:52 +0100 Subject: [PATCH 10/10] 10.22.1 --- package.json | 2 +- src/data/published.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index bcbd3d6d473..f6f0de4162b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ethereum-org-website", - "version": "10.22.0", + "version": "10.22.1", "license": "MIT", "private": true, "scripts": { diff --git a/src/data/published.json b/src/data/published.json index cf508724554..dc3d78c28e4 100644 --- a/src/data/published.json +++ b/src/data/published.json @@ -1 +1 @@ -{"date":"2026-01-23"} +{"date":"2026-01-29"}