diff --git a/src/app/compatibility/avm.tsx b/src/app/compatibility/avm.tsx
index 0acb110a..da50d41f 100644
--- a/src/app/compatibility/avm.tsx
+++ b/src/app/compatibility/avm.tsx
@@ -1,3 +1,5 @@
+"use client";
+
 import classes from "./avm.module.css";
 import {
   Button,
@@ -9,6 +11,7 @@ import {
   Title,
 } from "@mantine/core";
 import Link from "next/link";
+import { useTranslation } from "@/app/translate";
 
 interface AvmProgressProps {
   done: number;
@@ -21,17 +24,18 @@ interface AvmProgressPropsFull extends AvmProgressProps {
 }
 
 function AvmProgress(props: AvmProgressPropsFull) {
+  const { t } = useTranslation();
   return (
     <Group align="center" justify="spread-between" mt={props.mt}>
       <Text size="sm" className={classes.progressName}>
-        {props.name}: {props.done}%
+        {t(props.name)}: {props.done}%
       </Text>
       <ProgressRoot size="xl" radius={10} className={classes.progress}>
         <ProgressSection
           striped
           value={props.done}
           color="var(--mantine-color-green-9)"
-          title={`${props.done}% done`}
+          title={`${props.done}% ${t("compatibility.done")}`}
         ></ProgressSection>
         {props.stubbed && (
           <ProgressSection
@@ -39,7 +43,7 @@ function AvmProgress(props: AvmProgressPropsFull) {
             value={props.stubbed}
             color="ruffle-orange"
             className={classes.stub}
-            title={`${props.stubbed}% partially done`}
+            title={`${props.stubbed}% ${t("compatibility.partial")}`}
           ></ProgressSection>
         )}
       </ProgressRoot>
@@ -57,10 +61,11 @@ interface AvmBlockProps {
 }
 
 export function AvmBlock(props: AvmBlockProps) {
+  const { t } = useTranslation();
   return (
     <Stack className={classes.avm}>
       <Group justify="space-between">
-        <Title order={2}>{props.name}</Title>
+        <Title order={2}>{t(props.name)}</Title>
         <Button
           component={Link}
           href={props.info_link}
@@ -68,14 +73,18 @@ export function AvmBlock(props: AvmBlockProps) {
           size="compact-md"
           color="var(--ruffle-blue-7)"
         >
-          More Info
+          {t("compatibility.more")}
         </Button>
       </Group>
 
       {props.children}
 
-      <AvmProgress name="Language" mt="auto" {...props.language} />
-      <AvmProgress name="API" {...props.api} />
+      <AvmProgress
+        name="compatibility.language"
+        mt="auto"
+        {...props.language}
+      />
+      <AvmProgress name="compatibility.api" {...props.api} />
     </Stack>
   );
 }
diff --git a/src/app/compatibility/avm2/class_box.tsx b/src/app/compatibility/avm2/class_box.tsx
index 0d68b691..cfc26c05 100644
--- a/src/app/compatibility/avm2/class_box.tsx
+++ b/src/app/compatibility/avm2/class_box.tsx
@@ -14,11 +14,13 @@ import classes from "./avm2.module.css";
 import React from "react";
 import {
   ClassStatus,
-  ProgressIcon,
   displayedPercentage,
 } from "@/app/compatibility/avm2/report_utils";
+import { ProgressIcon } from "@/app/compatibility/avm2/icons";
+import { useTranslation } from "@/app/translate";
 
 export function ClassBox(props: ClassStatus) {
+  const { t } = useTranslation();
   const [opened, { toggle }] = useDisclosure(false);
   const pctDone = displayedPercentage(
     props.summary.impl_points - props.summary.stub_penalty,
@@ -33,13 +35,15 @@ export function ClassBox(props: ClassStatus) {
         );
   return (
     <Card bg="var(--ruffle-blue-9)" className={classes.class}>
-      <Title order={4}>{props.name || "(Package level)"}</Title>
+      <Title order={4}>
+        {props.name || t("compatibility.avm2.package-level")}
+      </Title>
       <ProgressRoot size="xl" radius={10} className={classes.progress}>
         <ProgressSection
           striped
           value={pctDone}
           color="var(--mantine-color-green-9)"
-          title={`${pctDone}% done`}
+          title={`${pctDone}% ${t("compatibility.done")}`}
         ></ProgressSection>
         {pctStub > 0 && (
           <ProgressSection
@@ -47,7 +51,7 @@ export function ClassBox(props: ClassStatus) {
             value={pctStub}
             color="ruffle-orange"
             className={classes.progressStub}
-            title={`${pctStub}% partially done`}
+            title={`${pctStub}% ${t("compatibility.partial")}`}
           ></ProgressSection>
         )}
       </ProgressRoot>
@@ -58,7 +62,10 @@ export function ClassBox(props: ClassStatus) {
             className={classes.showMemberButton}
             onClick={toggle}
           >
-            {opened ? "Hide" : "Show"} Missing Members
+            {opened
+              ? t("compatibility.avm2.hide")
+              : t("compatibility.avm2.show")}{" "}
+            {t("compatibility.avm2.missing-members")}
           </Button>
           <List hidden={!opened}>
             {props.items.map((item, i) => (
diff --git a/src/app/compatibility/avm2/icons.tsx b/src/app/compatibility/avm2/icons.tsx
new file mode 100644
index 00000000..5368c897
--- /dev/null
+++ b/src/app/compatibility/avm2/icons.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { rem, ThemeIcon } from "@mantine/core";
+import { IconCheck, IconProgress, IconX } from "@tabler/icons-react";
+import { useTranslation } from "@/app/translate";
+
+export function IconDone() {
+  const { t } = useTranslation();
+  return (
+    <ThemeIcon
+      size={20}
+      radius="xl"
+      color="var(--mantine-color-green-9)"
+      title={t("compatibility.avm2.done")}
+    >
+      <IconCheck
+        color="white"
+        style={{ width: rem(12), height: rem(12) }}
+        stroke={4}
+      />
+    </ThemeIcon>
+  );
+}
+
+export function IconStub() {
+  const { t } = useTranslation();
+  return (
+    <ThemeIcon size={20} radius="xl" title={t("compatibility.avm2.partial")}>
+      <IconProgress
+        color="#3c1518"
+        style={{ width: rem(12), height: rem(12) }}
+        stroke={4}
+      />
+    </ThemeIcon>
+  );
+}
+
+export function IconMissing() {
+  const { t } = useTranslation();
+  return (
+    <ThemeIcon
+      size={20}
+      radius="xl"
+      color="#3c1518"
+      title={t("compatibility.avm2.missing")}
+    >
+      <IconX
+        color="white"
+        style={{ width: rem(12), height: rem(12) }}
+        stroke={4}
+      />
+    </ThemeIcon>
+  );
+}
+
+export function ProgressIcon(type: "stub" | "missing" | "done") {
+  switch (type) {
+    case "stub":
+      return <IconStub />;
+    case "missing":
+      return <IconMissing />;
+    case "done":
+      return <IconDone />;
+  }
+}
diff --git a/src/app/compatibility/avm2/page.tsx b/src/app/compatibility/avm2/page.tsx
index 0b1d447c..6fc70812 100644
--- a/src/app/compatibility/avm2/page.tsx
+++ b/src/app/compatibility/avm2/page.tsx
@@ -1,3 +1,5 @@
+"use client";
+
 import {
   Container,
   Group,
@@ -8,22 +10,26 @@ import {
   Title,
 } from "@mantine/core";
 import Image from "next/image";
-import React from "react";
+import React, { useEffect, useState } from "react";
 import classes from "./avm2.module.css";
 import { ClassBox } from "@/app/compatibility/avm2/class_box";
 import {
   getReportByNamespace,
+  NamespaceStatus,
+} from "@/app/compatibility/avm2/report_utils";
+import {
   IconDone,
   IconMissing,
   IconStub,
-  NamespaceStatus,
-} from "@/app/compatibility/avm2/report_utils";
+} from "@/app/compatibility/avm2/icons";
 import Link from "next/link";
+import { useTranslation, Trans } from "@/app/translate";
 
 function NamespaceBox(props: NamespaceStatus) {
+  const { t } = useTranslation();
   return (
     <Stack className={classes.namespace}>
-      <Title order={2}>{props.name || "(Top Level)"}</Title>
+      <Title order={2}>{props.name || t("compatibility.avm2.top-level")}</Title>
       <Group align="baseline">
         {Object.entries(props.classes).map(([classname, classinfo]) => (
           <ClassBox key={classname} {...classinfo} />
@@ -33,8 +39,22 @@ function NamespaceBox(props: NamespaceStatus) {
   );
 }
 
-export default async function Page() {
-  const byNamespace = await getReportByNamespace();
+export default function Page() {
+  const { t } = useTranslation();
+  const [byNamespace, setByNamespace] = useState<
+    { [name: string]: NamespaceStatus } | undefined
+  >(undefined);
+  useEffect(() => {
+    const fetchData = async () => {
+      try {
+        const byNamespace = await getReportByNamespace();
+        setByNamespace(byNamespace);
+      } catch (error) {
+        console.error("Error fetching data", error);
+      }
+    };
+    fetchData();
+  }, []);
   return (
     <Container size="xl">
       <Stack gap="xl">
@@ -48,43 +68,52 @@ export default async function Page() {
             className={classes.progressImage}
           />
           <Stack className={classes.actionscriptInfo}>
-            <Title className={classes.title}>ActionScript 3 API Progress</Title>
-            <Text>
-              ActionScript 3 contains many different methods and classes - not
-              all of which is ultimately <i>useful</i> to every application. The
-              majority of content only uses a small portion of the available
-              API, so even if we aren&apos;t 100% &quot;complete&quot; across
-              the entirely of AVM 2, we may have enough for that content to run
-              completely fine.
-            </Text>
-            <Text>
-              On this page, we list every single ActionScript 3 API that exists
-              but Ruffle does not yet 100% implement. We classify items into
-              three different stages:
-            </Text>
+            <Title className={classes.title}>
+              {t("compatibility.avm2.title")}
+            </Title>
+            <Text>{t("compatibility.avm2.description")}</Text>
+            <Text>{t("compatibility.avm2.classification")}</Text>
             <List spacing="sm">
               <ListItem icon={<IconDone />}>
-                <b>Implemented</b> items are marked as &quot;Done&quot;, and we
-                believe they are fully functional. For brevity, we do not list
-                completed items on this page.
+                <Trans
+                  i18nKey="compatibility.avm2.implemented-description"
+                  components={[
+                    <b key="implemented">
+                      {t("compatibility.avm2.implemented")}
+                    </b>,
+                  ]}
+                />
               </ListItem>
               <ListItem icon={<IconStub />}>
-                <b>Partial</b> items exist and are enough for most content to
-                work, but are incomplete. A partial class may be missing items,
-                or a method may just simply return a value without performing
-                its intended function.
+                <Trans
+                  i18nKey="compatibility.avm2.partial-description"
+                  components={[
+                    <b key="partial">{t("compatibility.avm2.partial")}</b>,
+                  ]}
+                />
               </ListItem>
               <ListItem icon={<IconMissing />}>
-                <b>Missing</b> items do not exist at all in Ruffle yet, and
-                trying to use them will give an error.
+                <Trans
+                  i18nKey="compatibility.avm2.missing-description"
+                  components={[
+                    <b key="missing">{t("compatibility.avm2.missing")}</b>,
+                  ]}
+                />
               </ListItem>
             </List>
             <Text>
-              You can also visualize the progress{" "}
-              <Link href="/compatibility/avm2/tree.svg" target="_blank">
-                as a tree graph
-              </Link>
-              .
+              <Trans
+                i18nKey="compatibility.avm2.tree"
+                components={[
+                  <Link
+                    key="link"
+                    href="/compatibility/avm2/tree.svg"
+                    target="_blank"
+                  >
+                    {t("compatibility.avm2.tree-link")}
+                  </Link>,
+                ]}
+              />
             </Text>
           </Stack>
         </Group>
diff --git a/src/app/compatibility/avm2/report_utils.tsx b/src/app/compatibility/avm2/report_utils.tsx
index 73ebfb07..8ccb40d1 100644
--- a/src/app/compatibility/avm2/report_utils.tsx
+++ b/src/app/compatibility/avm2/report_utils.tsx
@@ -1,59 +1,4 @@
-import { rem, ThemeIcon } from "@mantine/core";
-import { IconCheck, IconProgress, IconX } from "@tabler/icons-react";
-import { fetchReport } from "@/app/downloads/github";
-import React from "react";
-
-export function IconDone() {
-  return (
-    <ThemeIcon
-      size={20}
-      radius="xl"
-      color="var(--mantine-color-green-9)"
-      title="Done"
-    >
-      <IconCheck
-        color="white"
-        style={{ width: rem(12), height: rem(12) }}
-        stroke={4}
-      />
-    </ThemeIcon>
-  );
-}
-
-export function IconStub() {
-  return (
-    <ThemeIcon size={20} radius="xl" title="Partial">
-      <IconProgress
-        color="#3c1518"
-        style={{ width: rem(12), height: rem(12) }}
-        stroke={4}
-      />
-    </ThemeIcon>
-  );
-}
-
-export function IconMissing() {
-  return (
-    <ThemeIcon size={20} radius="xl" color="#3c1518" title="Missing">
-      <IconX
-        color="white"
-        style={{ width: rem(12), height: rem(12) }}
-        stroke={4}
-      />
-    </ThemeIcon>
-  );
-}
-
-export function ProgressIcon(type: "stub" | "missing" | "done") {
-  switch (type) {
-    case "stub":
-      return <IconStub />;
-    case "missing":
-      return <IconMissing />;
-    case "done":
-      return <IconDone />;
-  }
-}
+import type { AVM2Report } from "@/app/downloads/config";
 
 export interface SummaryStatistics {
   max_points: number;
@@ -82,7 +27,8 @@ export async function getReportByNamespace(): Promise<
   { [name: string]: NamespaceStatus } | undefined
 > {
   let byNamespace: { [name: string]: NamespaceStatus } = {};
-  const report = await fetchReport();
+  const reportReq = await fetch("/compatibility/fetch-report");
+  const report: AVM2Report = await reportReq.json();
   if (!report) {
     return;
   }
diff --git a/src/app/compatibility/fetch-report/route.tsx b/src/app/compatibility/fetch-report/route.tsx
new file mode 100644
index 00000000..9111b48c
--- /dev/null
+++ b/src/app/compatibility/fetch-report/route.tsx
@@ -0,0 +1,25 @@
+import { NextResponse } from "next/server";
+import { fetchReport } from "@/app/downloads/github";
+import { AVM2Report } from "@/app/downloads/config";
+
+let cachedReport: AVM2Report | undefined;
+
+export async function GET() {
+  if (cachedReport) {
+    return NextResponse.json(cachedReport); // Return cached result
+  }
+
+  try {
+    const report = await fetchReport();
+    cachedReport = report; // Cache the result
+    return NextResponse.json(report);
+  } catch (error) {
+    console.error("Error fetching report:", error);
+    return NextResponse.json(
+      { error: "Failed to fetch report" },
+      { status: 500 },
+    );
+  }
+}
+
+export const dynamic = "force-static";
diff --git a/src/app/compatibility/page.tsx b/src/app/compatibility/page.tsx
index 2337e7fe..1498abba 100644
--- a/src/app/compatibility/page.tsx
+++ b/src/app/compatibility/page.tsx
@@ -1,38 +1,69 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
 import { Container, Flex, Group, Stack, Text } from "@mantine/core";
 import classes from "./compatibility.module.css";
 import { AvmBlock } from "@/app/compatibility/avm";
 import Image from "next/image";
-import React from "react";
 import { Title } from "@mantine/core";
 import { List, ListItem } from "@mantine/core";
 import { WeeklyContributions } from "@/app/compatibility/weekly_contributions";
 import {
-  fetchReport,
   getAVM1Progress,
   getWeeklyContributions,
 } from "@/app/downloads/github";
+import { useTranslation, Trans } from "@/app/translate";
+
+interface DataPoint {
+  week: string;
+  Commits: number;
+}
+
+export default function Downloads() {
+  const { t } = useTranslation();
+  const [data, setData] = useState<DataPoint[]>([]);
+  const [avm1ApiDone, setAvm1ApiDone] = useState<number>(0);
+  const [avm2ApiDone, setAvm2ApiDone] = useState<number>(0);
+  const [avm2ApiStubbed, setAvm2ApiStubbed] = useState<number>(0);
+  useEffect(() => {
+    const fetchData = async () => {
+      try {
+        // Fetch weekly contributions
+        const contributionsRes = await getWeeklyContributions();
+        const contributionsData = contributionsRes.data.map((item) => ({
+          week: new Date(item.week * 1000).toISOString().split("T")[0],
+          Commits: item.total,
+        }));
+        setData(contributionsData);
+
+        // Fetch AVM1 progress
+        const avm1ApiRes = await getAVM1Progress();
+        setAvm1ApiDone(avm1ApiRes);
 
-export default async function Downloads() {
-  const contributions = await getWeeklyContributions();
-  const data = contributions.data.map((item) => {
-    return {
-      week: new Date(item.week * 1000).toISOString().split("T")[0],
-      Commits: item.total,
+        // Fetch report
+        const reportReq = await fetch("/compatibility/fetch-report");
+        const reportRes = await reportReq.json();
+        if (reportRes) {
+          const { summary } = reportRes;
+          const maxPoints = summary.max_points;
+          const implPoints = summary.impl_points;
+          const stubPenalty = summary.stub_penalty;
+
+          const avm2ApiDone = Math.round(
+            ((implPoints - stubPenalty) / maxPoints) * 100,
+          );
+          setAvm2ApiDone(avm2ApiDone);
+
+          const avm2ApiStubbed = Math.round((stubPenalty / maxPoints) * 100);
+          setAvm2ApiStubbed(avm2ApiStubbed);
+        }
+      } catch (error) {
+        console.error("Error fetching data", error);
+      }
     };
-  });
-  const avm1ApiDone = await getAVM1Progress();
-  const report = await fetchReport();
-  if (!report) {
-    return;
-  }
-  const summary = report.summary;
-  const maxPoints = summary.max_points;
-  const implPoints = summary.impl_points;
-  const stubPenalty = summary.stub_penalty;
-  const avm2ApiDone = Math.round(
-    ((implPoints - stubPenalty) / maxPoints) * 100,
-  );
-  const avm2ApiStubbed = Math.round((stubPenalty / maxPoints) * 100);
+
+    fetchData();
+  }, []);
 
   return (
     <Container size="xl" className={classes.container}>
@@ -47,27 +78,23 @@ export default async function Downloads() {
             className={classes.actionscriptImage}
           />
           <Stack className={classes.actionscriptInfo}>
-            <Title className={classes.title}>ActionScript Compatibility</Title>
-            <Text>
-              The biggest factor in content compatibility is ActionScript; the
-              language that powers interactivity in games and applications made
-              with Flash. All Flash content falls in one of two categories,
-              depending on which version of the language was used to create it.
-            </Text>
-            <Text>
-              We track our progress in each AVM by splitting them up into two
-              different areas:
-            </Text>
+            <Title className={classes.title}>{t("compatibility.title")}</Title>
+            <Text>{t("compatibility.description")}</Text>
+            <Text>{t("compatibility.tracking")}</Text>
             <List>
               <ListItem>
-                The <b>Language</b> is the underlying virtual machine itself and
-                the language concepts that it understands, like variables and
-                classes and how they all interact together.
+                <Trans
+                  i18nKey="compatibility.language-description"
+                  components={[
+                    <b key="language">{t("compatibility.language")}</b>,
+                  ]}
+                />
               </ListItem>
               <ListItem>
-                The <b>API</b> is the original built-in methods and classes that
-                are available for this AVM, like the ability to interact with
-                objects on the stage or make web requests.
+                <Trans
+                  i18nKey="compatibility.api-description"
+                  components={[<b key="api">{t("compatibility.api")}</b>]}
+                />
               </ListItem>
             </List>
           </Stack>
@@ -81,53 +108,33 @@ export default async function Downloads() {
           className={classes.avms}
         >
           <AvmBlock
-            name="AVM 1: ActionScript 1 & 2"
+            name="compatibility.avm1-title"
             language={{ done: 95 }}
             api={{ done: avm1ApiDone }}
             info_link_target="_blank"
             info_link="https://github.com/ruffle-rs/ruffle/issues/310"
           >
-            <Text>
-              AVM 1 is the original ActionScript Virtual Machine. All movies
-              made before Flash Player 9 (June 2006) will be made with AVM 1,
-              and it remained supported &amp; available to authors until the
-              release of Flash Professional CC (2013), after which point content
-              started moving to AVM 2.
-            </Text>
-            <Text>
-              We believe that most AVM 1 content will work, but we are aware of
-              some graphical inaccuracies and smaller bugs here and there.
-              Please feel free to report any issues you find that are not
-              present in the original Flash Player!
-            </Text>
+            <Text>{t("compatibility.avm1-description")}</Text>
+            <Text>{t("compatibility.avm1-support")}</Text>
           </AvmBlock>
 
           <AvmBlock
-            name="AVM 2: ActionScript 3"
+            name="compatibility.avm2-title"
             language={{ done: 90 }}
             api={{ done: avm2ApiDone, stubbed: avm2ApiStubbed }}
             info_link="/compatibility/avm2"
           >
-            <Text>
-              AVM 2 was introduced with Flash Player 9 (June 2006), to replace
-              the earlier AVM 1. After the release of Flash Professional CC
-              (2013), authors are required to use ActionScript 3 - making any
-              movie made after that date very likely to fall under this
-              category.
-            </Text>
-            <Text>
-              Ruffle now has decent support for AVM 2, and it&apos;s our
-              experience that most games will work well enough to be played.
-              We&apos;re still rapidly improving in this area though, so bug
-              reports about any broken content are always welcome!
-            </Text>
+            <Text>{t("compatibility.avm2-description")}</Text>
+            <Text>{t("compatibility.avm2-support")}</Text>
           </AvmBlock>
         </Flex>
 
-        <Stack w="100%" align="center">
-          <Title order={2}>Weekly Contributions</Title>
-          <WeeklyContributions data={data} />
-        </Stack>
+        {data && (
+          <Stack w="100%" align="center">
+            <Title order={2}>{t("compatibility.weekly-contributions")}</Title>
+            <WeeklyContributions data={data} />
+          </Stack>
+        )}
       </Stack>
     </Container>
   );
diff --git a/src/app/compatibility/weekly_contributions.tsx b/src/app/compatibility/weekly_contributions.tsx
index bbd4424c..f0406136 100644
--- a/src/app/compatibility/weekly_contributions.tsx
+++ b/src/app/compatibility/weekly_contributions.tsx
@@ -2,6 +2,7 @@
 import { BarChart } from "@mantine/charts";
 import { Paper, Text } from "@mantine/core";
 import classes from "./weekly_contributions.module.css";
+import { Trans } from "@/app/translate";
 
 interface DataPoint {
   week: string;
@@ -23,7 +24,10 @@ function ChartTooltip({ label, payload }: ChartTooltipProps) {
   return (
     <Paper px="md" py="sm" withBorder shadow="md" radius="md">
       <Text fw={500} mb={5}>
-        {commits.value} commits on the week of {label}
+        <Trans
+          i18nKey="compatibility.commits-description"
+          values={{ commitNumber: commits.value, week: label }}
+        />
       </Text>
     </Paper>
   );
diff --git a/src/app/contribute/page.tsx b/src/app/contribute/page.tsx
index 44f9c9ef..aeffd33c 100644
--- a/src/app/contribute/page.tsx
+++ b/src/app/contribute/page.tsx
@@ -1,3 +1,5 @@
+"use client";
+
 import {
   Container,
   Group,
@@ -13,142 +15,166 @@ import Image from "next/image";
 import { IconBrandRust, IconBrandTypescript } from "@tabler/icons-react";
 import Link from "next/link";
 import { SponsorList } from "@/app/contribute/sponsors";
+import { useTranslation, Trans } from "@/app/translate";
 
 function ContributeCode() {
+  const { t } = useTranslation();
   return (
     <Stack className={classes.contributeSection}>
-      <Title className={classes.title}>🖥️ Contribute code</Title>
-      <Text>
-        There's a few different codebases in couple of different languages, and
-        we'd welcome any help to try and maintain and improve them.
-      </Text>
+      <Title className={classes.title}>🖥️ {t("contribute.code")}</Title>
+      <Text>{t("contribute.code-description")}</Text>
       <List>
         <ListItem icon={<IconBrandRust />}>
-          The actual{" "}
-          <Link target="_blank" href="https://github.com/ruffle-rs/ruffle/">
-            emulator
-          </Link>{" "}
-          itself, and all of the{" "}
-          <Link
-            target="_blank"
-            href="https://github.com/ruffle-rs/ruffle/tree/master/desktop"
-          >
-            desktop player
-          </Link>
-          , is written in Rust.
+          <Trans
+            i18nKey="contribute.rust"
+            components={[
+              <Link
+                key="emulator"
+                target="_blank"
+                href="https://github.com/ruffle-rs/ruffle/"
+              >
+                {t("contribute.emulator")}
+              </Link>,
+              <Link
+                key="desktop-player"
+                target="_blank"
+                href="https://github.com/ruffle-rs/ruffle/tree/master/desktop"
+              >
+                {t("contribute.desktop-player")}
+              </Link>,
+            ]}
+          />
         </ListItem>
         <ListItem icon={<IconBrandTypescript />}>
-          The{" "}
-          <Link
-            target="_blank"
-            href="https://github.com/ruffle-rs/ruffle/tree/master/web"
-          >
-            web player
-          </Link>
-          , the{" "}
-          <Link
-            target="_blank"
-            href="https://github.com/ruffle-rs/ruffle/tree/master/web/packages/extension"
-          >
-            extension
-          </Link>{" "}
-          and our{" "}
-          <Link
-            target="_blank"
-            href="https://github.com/ruffle-rs/ruffle-rs.github.io"
-          >
-            website
-          </Link>{" "}
-          is written in TypeScript.
+          <Trans
+            i18nKey="contribute.typescript"
+            components={[
+              <Link
+                key="web-player"
+                target="_blank"
+                href="https://github.com/ruffle-rs/ruffle/tree/master/web"
+              >
+                {t("contribute.web-player")}
+              </Link>,
+              <Link
+                key="extension"
+                target="_blank"
+                href="https://github.com/ruffle-rs/ruffle/tree/master/web/packages/extension"
+              >
+                {t("contribute.extension")}
+              </Link>,
+              <Link
+                key="website"
+                target="_blank"
+                href="https://github.com/ruffle-rs/ruffle-rs.github.io"
+              >
+                {t("contribute.website")}
+              </Link>,
+            ]}
+          />
         </ListItem>
       </List>
       <Text>
-        Check out our{" "}
-        <Link
-          target="_blank"
-          href="https://github.com/ruffle-rs/ruffle/blob/master/CONTRIBUTING.md"
-        >
-          Contributing Guidelines
-        </Link>{" "}
-        for information on how to start, and come join our{" "}
-        <Link target="_blank" href="https://discord.gg/ruffle">
-          Discord
-        </Link>{" "}
-        if you need help!
+        <Trans
+          i18nKey="contribute.getting-started"
+          components={[
+            <Link
+              key="guidelines"
+              target="_blank"
+              href="https://github.com/ruffle-rs/ruffle/blob/master/CONTRIBUTING.md"
+            >
+              {t("contribute.guidelines")}
+            </Link>,
+            <Link
+              key="discord"
+              target="_blank"
+              href="https://discord.gg/ruffle"
+            >
+              {t("footer.discord")}
+            </Link>,
+          ]}
+        />
       </Text>
     </Stack>
   );
 }
 
 function TestContent() {
+  const { t } = useTranslation();
   return (
     <Stack className={classes.contributeSection}>
-      <Title className={classes.title}>🕹️ Test content</Title>
+      <Title className={classes.title}>🕹️ {t("contribute.test")}</Title>
+      <Text>{t("contribute.test-description")}</Text>
       <Text>
-        Arguably more important than contributing code is testing Ruffle out. Go
-        install Ruffle and try out your favourite games and animations. Look for
-        any difference from the official Flash Player, and report your findings
-        to us.
-      </Text>
-      <Text>
-        If you find any bugs, changes of behaviour, performance issues or any
-        visual differences then please report those to{" "}
-        <Link target="_blank" href="https://github.com/ruffle-rs/ruffle/issues">
-          our bug tracker
-        </Link>
-        .
+        <Trans
+          i18nKey="contribute.report-bugs"
+          components={[
+            <Link
+              key="bug-tracker"
+              target="_blank"
+              href="https://github.com/ruffle-rs/ruffle/issues"
+            >
+              {t("contribute.bug-tracker")}
+            </Link>,
+          ]}
+        />
       </Text>
       <Text>
-        If it runs flawlessly, come share the good news on{" "}
-        <Link target="_blank" href="https://discord.gg/ruffle">
-          our Discord
-        </Link>
-        !
+        <Trans
+          i18nKey="contribute.working"
+          components={[
+            <Link
+              key="our-discord"
+              target="_blank"
+              href="https://discord.gg/ruffle"
+            >
+              {t("contribute.our-discord")}
+            </Link>,
+          ]}
+        />
       </Text>
     </Stack>
   );
 }
 
 function Sponsorship() {
+  const { t } = useTranslation();
   return (
     <Stack className={classes.contributeSection}>
-      <Title className={classes.title}>💲 Sponsor the project</Title>
-      <Text>
-        If you are able and willing to, we welcome any and all financial support
-        to help us fund the project going forward. With your help, we can afford
-        to spend more time dedicated to Ruffle, as well as pay for expenses such
-        as build servers &amp; hosting. We accept donations and sponsorships of
-        any kind, big or small, through Open Source Collective 501(c)(6).
-      </Text>
+      <Title className={classes.title}>💲 {t("contribute.sponsor")}</Title>
+      <Text>{t("contribute.sponsor-description")}</Text>
       <Text>
-        For more information, or to view the options available for sponsoring
-        the project, please visit{" "}
-        <Link target="_blank" href="https://opencollective.com/ruffle">
-          our Open Collective page
-        </Link>
-        .
+        <Trans
+          i18nKey="contribute.sponsor-info"
+          components={[
+            <Link
+              key="opencollective"
+              target="_blank"
+              href="https://opencollective.com/ruffle"
+            >
+              {t("contribute.opencollective")}
+            </Link>,
+          ]}
+        />
       </Text>
     </Stack>
   );
 }
 
 function SpreadTheWord() {
+  const { t } = useTranslation();
   return (
     <Stack className={classes.contributeSection}>
-      <Title className={classes.title}>💬 Spread the word!</Title>
-      <Text>
-        Is your favourite Flash-based site shutting down? Let them know they can
-        add one JavaScript file and keep it running! Feeling nostalgic for some
-        old Flash games? Go play some on Newgrounds with Ruffle installed, and
-        tell your friends about it! Maybe you're a streamer and looking for some
-        silly content? There's literally decades worth, now unlocked and
-        accessible once more.
-      </Text>
+      <Title className={classes.title}>
+        💬 {t("contribute.spread-the-word")}
+      </Title>
+      <Text>{t("contribute.spread-the-word-description")}</Text>
     </Stack>
   );
 }
 
 export default function Page() {
+  const { t } = useTranslation();
   return (
     <Container size="xl" className={classes.container}>
       <Group align="top" wrap="nowrap">
@@ -161,16 +187,8 @@ export default function Page() {
           className={classes.image}
         />
         <Stack className={classes.actionscriptInfo}>
-          <Title className={classes.title}>Get Involved</Title>
-          <Text>
-            Ruffle is an entirely open source project, maintained by volunteers
-            like you who just want to help preserve a slice of history. We rely
-            on contributions of any kind to keep this project going, and
-            absolutely would not have come as far as we have without the amazing
-            support of our community who came together to make Ruffle happen. If
-            you'd like to join them, there are many ways to help make Ruffle
-            better than ever!
-          </Text>
+          <Title className={classes.title}>{t("contribute.involved")}</Title>
+          <Text>{t("contribute.involved-description")}</Text>
         </Stack>
       </Group>
 
diff --git a/src/app/contribute/sponsors.tsx b/src/app/contribute/sponsors.tsx
index 4f22586e..79a1b929 100644
--- a/src/app/contribute/sponsors.tsx
+++ b/src/app/contribute/sponsors.tsx
@@ -1,8 +1,11 @@
+"use client";
+
 import { Card, Group, Stack, Text, Title } from "@mantine/core";
 import classes from "./sponsors.module.css";
 import React from "react";
 import Link from "next/link";
 import Image from "next/image";
+import { useTranslation } from "@/app/translate";
 
 interface Sponsor {
   name: string;
@@ -115,14 +118,11 @@ function Sponsor(sponsor: Sponsor) {
 }
 
 export function SponsorList() {
+  const { t } = useTranslation();
   return (
     <Stack className={classes.list}>
-      <Title id="sponsors">💎 Diamond Sponsors</Title>
-      <Text>
-        We'd like to thank all of our sponsors, who help make this project
-        possible. Below are our Diamond level sponsors, without whom we would
-        not be here. Thank you.
-      </Text>
+      <Title id="sponsors">💎 {t("contribute.diamond")}</Title>
+      <Text>{t("contribute.diamond-description")}</Text>
       <Group justify="center" gap="sm">
         {sponsors.map((sponsor, index) => (
           <Sponsor key={index} {...sponsor} />
diff --git a/src/app/downloads/config.tsx b/src/app/downloads/config.tsx
index 061a62bc..09516e28 100644
--- a/src/app/downloads/config.tsx
+++ b/src/app/downloads/config.tsx
@@ -115,32 +115,32 @@ export interface DownloadLink {
 export const desktopLinks: DownloadLink[] = [
   {
     key: "windows_64",
-    shortName: "Windows (64-bit)",
-    longName: "Windows Executable",
+    shortName: "installers.windows-64-short-name",
+    longName: "installers.windows-long-name",
     icon: IconBrandWindows,
     isRecommended: true,
     isDeviceRelevant: (device) => device.desktop && device.windows,
   },
   {
     key: "windows_32",
-    shortName: "Windows (32-bit)",
-    longName: "Windows Executable",
+    shortName: "installers.windows-32-short-name",
+    longName: "installers.windows-long-name",
     icon: IconBrandWindows,
     isRecommended: false,
     isDeviceRelevant: () => false,
   },
   {
     key: "macos",
-    shortName: "macOS",
-    longName: "Mac Application",
+    shortName: "installers.macos-short-name",
+    longName: "installers.macos-long-name",
     icon: IconBrandApple,
     isRecommended: true,
     isDeviceRelevant: (device) => device.desktop && device.mac,
   },
   {
     key: "flatpak",
-    shortName: "Flatpak",
-    longName: "Flatpak App",
+    shortName: "installers.flatpak-short-name",
+    longName: "installers.flatpak-long-name",
     icon: IconBox,
     isRecommended: true,
     recommendedUrl: flathubUrl,
@@ -148,8 +148,8 @@ export const desktopLinks: DownloadLink[] = [
   },
   {
     key: "linux",
-    shortName: "Linux",
-    longName: "Linux Executable",
+    shortName: "installers.linux-short-name",
+    longName: "installers.linux-long-name",
     icon: IconBrandLinux,
     isRecommended: true,
     isDeviceRelevant: (device) => device.desktop && device.linux,
@@ -159,8 +159,8 @@ export const desktopLinks: DownloadLink[] = [
 export const extensionLinks: DownloadLink[] = [
   {
     key: "chromium",
-    shortName: "Chrome",
-    longName: "Chrome Extension",
+    shortName: "installers.chrome-short-name",
+    longName: "installers.chrome-long-name",
     icon: IconBrandChrome,
     isDeviceRelevant: (device) => device.desktop && device.chrome,
     isRecommended: true,
@@ -169,8 +169,8 @@ export const extensionLinks: DownloadLink[] = [
   },
   {
     key: "chromium",
-    shortName: "Edge",
-    longName: "Edge Extension",
+    shortName: "installers.edge-short-name",
+    longName: "installers.edge-long-name",
     icon: IconBrandEdge,
     isDeviceRelevant: (device) => device.desktop && device.edge,
     isRecommended: true,
@@ -179,8 +179,8 @@ export const extensionLinks: DownloadLink[] = [
   },
   {
     key: "firefox",
-    shortName: "Firefox",
-    longName: "Firefox Extension",
+    shortName: "installers.firefox-short-name",
+    longName: "installers.firefox-long-name",
     icon: IconBrandFirefox,
     isDeviceRelevant: (device) =>
       (device.desktop || device.android) && device.firefox,
@@ -189,8 +189,8 @@ export const extensionLinks: DownloadLink[] = [
   },
   {
     key: "macos",
-    shortName: "Safari",
-    longName: "Safari Extension",
+    shortName: "installers.safari-short-name",
+    longName: "installers.safari-long-name",
     icon: IconBrandSafari,
     isRecommended: true,
     isDeviceRelevant: () => false,
@@ -200,8 +200,8 @@ export const extensionLinks: DownloadLink[] = [
 export const webLinks: DownloadLink[] = [
   {
     key: "web",
-    shortName: "Self Hosted",
-    longName: "Website Package",
+    shortName: "installers.selfhosted-short-name",
+    longName: "installers.selfhosted-long-name",
     icon: IconBrandJavascript,
     isRecommended: true,
     isDeviceRelevant: () => false,
diff --git a/src/app/downloads/extensions.tsx b/src/app/downloads/extensions.tsx
index 95784279..7a1bbae4 100644
--- a/src/app/downloads/extensions.tsx
+++ b/src/app/downloads/extensions.tsx
@@ -1,8 +1,11 @@
+"use client";
+
 import { Group, Stack, Text, Title } from "@mantine/core";
 import classes from "./extensions.module.css";
 import React from "react";
 import Image from "next/image";
 import Link from "next/link";
+import { useTranslation } from "@/app/translate";
 
 interface Extension {
   image: string;
@@ -14,26 +17,27 @@ const extensions: Extension[] = [
   {
     image: "/extension_badges/chrome.svg",
     url: "https://chromewebstore.google.com/detail/ruffle-flash-emulator/donbcfbmhbcapadipfkeojnmajbakjdc",
-    alt: "Available in the Chrome Web Store",
+    alt: "downloads.chrome-extension-alt",
   },
   {
     image: "/extension_badges/firefox.svg",
     url: "https://addons.mozilla.org/firefox/addon/ruffle_rs",
-    alt: "Get the Add-On for Firefox",
+    alt: "downloads.firefox-extension-alt",
   },
   {
     image: "/extension_badges/edge.svg",
     url: "https://microsoftedge.microsoft.com/addons/detail/ruffle/pipjjbgofgieknlpefmcckdmgaaegban",
-    alt: "Get it from Microsoft for Edge",
+    alt: "downloads.edge-extension-alt",
   },
 ];
 
 function ExtensionBadge(info: Extension) {
+  const { t } = useTranslation();
   return (
     <Link href={info.url} target="_blank">
       <Image
         src={info.image}
-        alt={info.alt}
+        alt={t(info.alt)}
         width={0} // set by css to 100% width
         height={66}
         priority
@@ -44,17 +48,13 @@ function ExtensionBadge(info: Extension) {
 }
 
 export function ExtensionList() {
+  const { t } = useTranslation();
   return (
     <Stack>
       <Title id="extension" className={classes.title}>
-        Browser Extension
+        {t("downloads.browser-extension")}
       </Title>
-      <Text>
-        If you visit websites that have Flash content but aren't using Ruffle,
-        or you want to ensure you're using the latest and greatest version of
-        Ruffle on every website, then our browser extension is the perfect thing
-        for you!
-      </Text>
+      <Text>{t("downloads.browser-extension-description")}</Text>
       <Group>
         {extensions.map((extension, i) => (
           <ExtensionBadge key={i} {...extension} />
diff --git a/src/app/downloads/github.tsx b/src/app/downloads/github.tsx
index b4668444..22047860 100644
--- a/src/app/downloads/github.tsx
+++ b/src/app/downloads/github.tsx
@@ -73,6 +73,7 @@ export async function getWeeklyContributions(): Promise<
   const octokit = new Octokit({ authStrategy: createGithubAuth });
   return octokit.rest.repos.getCommitActivityStats(repository);
 }
+
 export async function fetchReport(): Promise<AVM2Report | undefined> {
   const releases = await getLatestReleases();
   const latest = releases.find(
diff --git a/src/app/downloads/nightlies.tsx b/src/app/downloads/nightlies.tsx
index cef20efb..a07231c7 100644
--- a/src/app/downloads/nightlies.tsx
+++ b/src/app/downloads/nightlies.tsx
@@ -24,6 +24,7 @@ import {
   githubReleasesUrl,
   webLinks,
 } from "@/app/downloads/config";
+import { useTranslation, Trans } from "@/app/translate";
 
 function DownloadLink({
   link,
@@ -32,6 +33,7 @@ function DownloadLink({
   link: DownloadLink;
   release: GithubRelease;
 }) {
+  const { t } = useTranslation();
   const url = release.downloads[link.key];
   if (!url) {
     return <></>;
@@ -49,7 +51,7 @@ function DownloadLink({
       title={url ? "" : "Unavailable"}
     >
       <link.icon />
-      {link.shortName}
+      {t(link.shortName)}
     </Button>
   );
 }
@@ -90,24 +92,25 @@ function NightlyRow(release: GithubRelease) {
 }
 
 function NightlyCompactBox(release: GithubRelease) {
+  const { t } = useTranslation();
   return (
     <>
       <Link href={release.url} className={classes.nameAsHeader} target="_blank">
         {release.name}
       </Link>
-      <Title order={5}>Desktop Application</Title>
+      <Title order={5}>{t("downloads.desktop-app")}</Title>
       <Group>
         {desktopLinks.map((link, index) => (
           <DownloadLink key={index} link={link} release={release} />
         ))}
       </Group>
-      <Title order={5}>Browser Extension</Title>
+      <Title order={5}>{t("downloads.browser-extension")}</Title>
       <Group>
         {extensionLinks.map((link, index) => (
           <DownloadLink key={index} link={link} release={release} />
         ))}
       </Group>
-      <Title order={5}>Web Package</Title>
+      <Title order={5}>{t("downloads.web-package")}</Title>
       <Group>
         {webLinks.map((link, index) => (
           <DownloadLink key={index} link={link} release={release} />
@@ -117,23 +120,29 @@ function NightlyCompactBox(release: GithubRelease) {
   );
 }
 
-export function NightlyList({ nightlies }: { nightlies: GithubRelease[] }) {
+export function NightlyList({
+  nightlies,
+}: {
+  nightlies: GithubRelease[] | null;
+}) {
+  const { t } = useTranslation();
   return (
     <Stack>
-      <Title id="nightly-releases">Nightly Releases</Title>
+      <Title id="nightly-releases">{t("downloads.nightly-releases")}</Title>
       <Text>
-        If none of the above are suitable for you, you can manually download the
-        latest Nightly release. These are automatically built every day
-        (approximately midnight UTC), unless there are no changes on that day.{" "}
-        Older nightly releases are available on{" "}
-        <Link
-          href={githubReleasesUrl}
-          className={classes.moreNightlies}
-          target="_blank"
-        >
-          GitHub
-        </Link>
-        .
+        <Trans
+          i18nKey="downloads.nightly-releases-description"
+          components={[
+            <Link
+              key="link"
+              href={githubReleasesUrl}
+              className={classes.moreNightlies}
+              target="_blank"
+            >
+              {t("footer.github")}
+            </Link>,
+          ]}
+        />
       </Text>
       <Table
         horizontalSpacing="md"
@@ -144,14 +153,14 @@ export function NightlyList({ nightlies }: { nightlies: GithubRelease[] }) {
       >
         <TableThead className={classes.header}>
           <TableTr>
-            <TableTh>Version</TableTh>
-            <TableTh>Desktop Application</TableTh>
-            <TableTh>Browser Extension</TableTh>
-            <TableTh>Web Package</TableTh>
+            <TableTh>{t("downloads.version")}</TableTh>
+            <TableTh>{t("downloads.desktop-app")}</TableTh>
+            <TableTh>{t("downloads.browser-extension")}</TableTh>
+            <TableTh>{t("downloads.web-package")}</TableTh>
           </TableTr>
         </TableThead>
         <TableTbody className={classes.body}>
-          {nightlies.map((nightly) => (
+          {nightlies?.map((nightly) => (
             <NightlyRow key={nightly.id} {...nightly} />
           ))}
         </TableTbody>
@@ -159,7 +168,7 @@ export function NightlyList({ nightlies }: { nightlies: GithubRelease[] }) {
 
       <Stack hiddenFrom="sm">
         {/*Compact mobile view, because a table is far too wide*/}
-        {nightlies.map((nightly) => (
+        {nightlies?.map((nightly) => (
           <NightlyCompactBox key={nightly.id} {...nightly} />
         ))}
       </Stack>
diff --git a/src/app/downloads/page.tsx b/src/app/downloads/page.tsx
index 622d6cc7..aee73f02 100644
--- a/src/app/downloads/page.tsx
+++ b/src/app/downloads/page.tsx
@@ -1,3 +1,5 @@
+"use client";
+
 import {
   Button,
   Code,
@@ -19,53 +21,57 @@ import {
   maxNightlies,
 } from "@/app/downloads/config";
 import { getLatestReleases } from "@/app/downloads/github";
+import { useTranslation, Trans } from "@/app/translate";
 
 function WebDownload({ latest }: { latest: GithubRelease | null }) {
+  const { t } = useTranslation();
   return (
     <Stack>
-      <Title id="website-package">Website Package</Title>
-      <Text>
-        You can install Ruffle onto a website using one single line of code by
-        using a CDN, no extra work required! It'll always stay up to date with
-        the latest available version of Ruffle.
-      </Text>
+      <Title id="website-package">{t("installers.selfhosted-long-name")}</Title>
+      <Text>{t("downloads.web-package-description")}</Text>
       <Code block className={classes.cdn}>
         {'<script src="https://unpkg.com/@ruffle-rs/ruffle"></script>'}
       </Code>
       <Text>
-        If you'd like to host it yourself, you can grab{" "}
-        <Link
-          href={latest?.downloads?.web || githubReleasesUrl}
-          target="_blank"
-        >
-          the latest self-hosted package
-        </Link>{" "}
-        and upload it to your server. Then, include it on your page like so:
+        <Trans
+          i18nKey="downloads.self-host-description"
+          components={[
+            <Link
+              key="link"
+              href={latest?.downloads?.web || githubReleasesUrl}
+              target="_blank"
+            >
+              {t("downloads.self-host-description-link")}
+            </Link>,
+          ]}
+        />
       </Text>
       <Code block className={classes.cdn}>
         {'<script src="path/to/ruffle.js"></script>'}
       </Code>
       <Text>
-        For advanced usage, consult{" "}
-        <Link href="https://github.com/ruffle-rs/ruffle/wiki/Using-Ruffle#javascript-api">
-          our documentation
-        </Link>{" "}
-        for our JavaScript API and installation options.
+        <Trans
+          i18nKey="downloads.advanced-usage-description"
+          components={[
+            <Link
+              key="link"
+              href="https://github.com/ruffle-rs/ruffle/wiki/Using-Ruffle#javascript-api"
+            >
+              {t("downloads.advanced-usage-description-link")}
+            </Link>,
+          ]}
+        />
       </Text>
     </Stack>
   );
 }
 
 function DesktopDownload({ latest }: { latest: GithubRelease | null }) {
+  const { t } = useTranslation();
   return (
     <Stack>
-      <Title id="desktop-app">Desktop Application</Title>
-      <Text>
-        If you want to run Flash content on your computer without a browser
-        in-between, we have native applications that will take full advantage of
-        your GPU and system resources to get those extra frames when playing
-        intense games.
-      </Text>
+      <Title id="desktop-app">{t("downloads.desktop-app")}</Title>
+      <Text>{t("downloads.desktop-app-description")}</Text>
       <Group>
         {desktopLinks
           .filter((link) => link.isRecommended)
@@ -84,7 +90,7 @@ function DesktopDownload({ latest }: { latest: GithubRelease | null }) {
                 title={url ? "" : "Unavailable"}
               >
                 <link.icon />
-                {link.shortName}
+                {t(link.shortName)}
               </Button>
             );
           })}
@@ -93,12 +99,26 @@ function DesktopDownload({ latest }: { latest: GithubRelease | null }) {
   );
 }
 
-export default async function Page() {
-  const releases = await getLatestReleases();
-  const latest = releases.length > 0 ? releases[0] : null;
-  const nightlies = releases
-    .filter((release) => release.prerelease)
-    .slice(0, maxNightlies);
+export default function Page() {
+  const [latest, setLatest] = React.useState<GithubRelease | null>(null);
+  const [nightlies, setNightlies] = React.useState<GithubRelease[] | null>(
+    null,
+  );
+  React.useEffect(() => {
+    const fetchReleases = async () => {
+      try {
+        const releases = await getLatestReleases();
+        const nightlies = releases
+          .filter((release) => release.prerelease)
+          .slice(0, maxNightlies);
+        setNightlies(nightlies);
+        setLatest(releases.length > 0 ? releases[0] : null);
+      } catch (err) {
+        console.warn("Failed to fetch releases", err);
+      }
+    };
+    fetchReleases();
+  }, []);
   return (
     <Container size="xl" className={classes.container}>
       <Stack gap="xl">
diff --git a/src/app/installers.tsx b/src/app/installers.tsx
index d53e0b08..547f167e 100644
--- a/src/app/installers.tsx
+++ b/src/app/installers.tsx
@@ -6,6 +6,7 @@ import { useDeviceSelectors } from "react-device-detect";
 import classes from "./index.module.css";
 import { Button, Group } from "@mantine/core";
 import Link from "next/link";
+import { useTranslation } from "@/app/translate";
 import { allLinks, CurrentDevice, GithubRelease } from "@/app/downloads/config";
 
 interface RecommendedDownload {
@@ -21,6 +22,7 @@ export default function Installers({
 }: {
   release: GithubRelease | null;
 }) {
+  const { t } = useTranslation();
   const [selectors] = useDeviceSelectors(window.navigator.userAgent);
   const recommended: RecommendedDownload[] = [];
   const currentDevice: CurrentDevice = {
@@ -52,14 +54,14 @@ export default function Installers({
   }
 
   recommended.push({
-    name: "Website Package",
+    name: "installers.selfhosted-long-name",
     icon: IconBrandJavascript,
     url: "/downloads#website-package",
   });
 
   recommended.push({
     icon: IconList,
-    name: "Other Downloads",
+    name: "installers.other-downloads",
     url: "/downloads",
     className: classes.otherDownloadsButton,
   });
@@ -77,7 +79,7 @@ export default function Installers({
           target={download.target}
         >
           <download.icon />
-          {download.name}
+          {t(download.name)}
         </Button>
       ))}
     </Group>
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index a116d776..bcac48d9 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -1,13 +1,17 @@
+"use client";
+
 import classes from "./not-found.module.css";
+import { useTranslation } from "@/app/translate";
 import { Stack, Text, Title } from "@mantine/core";
 import React from "react";
 
 export default function Home() {
+  const { t } = useTranslation();
   return (
     <Stack align="center">
       <Title className={classes.title}>404</Title>
-      <Title order={2}>Page not found :(</Title>
-      <Text>The requested page could not be found.</Text>
+      <Title order={2}>{t("404.not-found")} :(</Title>
+      <Text>{t("404.not-found-description")}</Text>
     </Stack>
   );
 }
diff --git a/src/app/page.tsx b/src/app/page.tsx
index c1e2eee1..ac9cb77c 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -15,6 +15,7 @@ import Image from "next/image";
 import { IconCheck } from "@tabler/icons-react";
 import React from "react";
 import { getLatestReleases } from "@/app/downloads/github";
+import { useTranslation } from "@/app/translate";
 import { GithubRelease } from "./downloads/config";
 
 const InteractiveLogo = dynamic(() => import("../components/logo"), {
@@ -26,6 +27,7 @@ const Installers = dynamic(() => import("./installers"), {
 });
 
 export default function Home() {
+  const { t } = useTranslation();
   const [latest, setLatest] = React.useState<GithubRelease | null>(null);
 
   React.useEffect(() => {
@@ -45,9 +47,7 @@ export default function Home() {
       <InteractiveLogo className={classes.logo} />
 
       <Container size="md">
-        <Title className={classes.title}>
-          An open source Flash Player emulator
-        </Title>
+        <Title className={classes.title}>{t("home.title")}</Title>
         <div className={classes.hero}>
           <Image
             className={classes.heroImage}
@@ -58,10 +58,7 @@ export default function Home() {
             priority
           />
           <div className={classes.heroInner}>
-            <Text mt="md">
-              Made to run natively on all modern operating systems and browsers,
-              Ruffle brings Flash content back to life with no extra fuss.
-            </Text>
+            <Text mt="md">{t("home.intro")}</Text>
 
             <List
               mt={30}
@@ -78,18 +75,16 @@ export default function Home() {
               }
             >
               <ListItem>
-                <b className={classes.key}>Safe to use</b> - Using the
-                guarantees of Rust and WASM, we avoid the security pitfalls
-                Flash was known for.
+                <b className={classes.key}>{t("home.safe")}</b> -{" "}
+                <span>{t("home.safe-description")}</span>
               </ListItem>
               <ListItem>
-                <b className={classes.key}>Easy to install</b> - Whether
-                you&apos;re a user or a website owner, we&apos;ve made it as
-                easy as possible to get up and running.
+                <b className={classes.key}>{t("home.easy")}</b> -{" "}
+                <span>{t("home.easy-description")}</span>
               </ListItem>
               <ListItem>
-                <b className={classes.key}>Free and open source</b> - Licensed
-                MIT/Apache 2.0, you&apos;re free to use Ruffle how you please!
+                <b className={classes.key}>{t("home.free")}</b> -{" "}
+                <span>{t("home.free-description")}</span>
               </ListItem>
             </List>
 
diff --git a/src/app/translate.tsx b/src/app/translate.tsx
new file mode 100644
index 00000000..4078d3b9
--- /dev/null
+++ b/src/app/translate.tsx
@@ -0,0 +1,220 @@
+"use client";
+
+import React, { useEffect, useState, useCallback } from "react";
+import defaultTranslations from "@/i18n/translations.en.json";
+import classes from "@/components/header.module.css";
+
+const languages = {
+  en: "English (United States)",
+  ar: "العربية",
+  ca: "Català",
+  "zh-CN": "简体中文",
+  "zh-TW": "繁體中文",
+  cs: "Čeština",
+  nl: "Nederlands",
+  fr: "Français (France)",
+  de: "Deutsch",
+  he: "עברית (ישראל)",
+  hu: "Magyar",
+  id: "Indonesian",
+  it: "Italiano (Italia)",
+  ja: "日本語",
+  ko: "한국어",
+  pl: "Polski (Polska)",
+  "pt-PT": "Português (Portugal)",
+  "pt-BR": "Português brasileiro",
+  ro: "Romanian",
+  ru: "Русский",
+  sk: "Slovenčina (Slovensko)",
+  "es-ES": "Español",
+  "sv-SE": "Svenska",
+  tr: "Türkçe",
+  uk: "Українська",
+};
+
+type TranslationObject = {
+  [key: string]: string | TranslationObject;
+};
+
+interface LanguageSelectorProps {
+  className?: string;
+}
+
+async function getAvailableLanguage() {
+  const defaultLanguage = "en";
+  const storedLanguage = window.localStorage.getItem("next-export-i18n-lang");
+  const browserLanguage =
+    window.navigator.language ||
+    (window.navigator.languages && window.navigator.languages[0]);
+  const language = storedLanguage || browserLanguage || defaultLanguage;
+
+  // Helper function to check if a language file exists
+  const checkLanguageFileExists = async (lang: string) => {
+    try {
+      await import(`@/i18n/translations.${lang}.json`);
+      return lang;
+    } catch {
+      console.warn(`Translation file for language "${lang}" not found.`);
+      return null;
+    }
+  };
+
+  // Check for full and base language, then fallback to default language
+  const lang =
+    (await checkLanguageFileExists(language)) ||
+    (await checkLanguageFileExists(language.split("-")[0])) ||
+    defaultLanguage;
+  return lang;
+}
+
+const getNestedTranslation = (
+  obj: TranslationObject,
+  key: string,
+): string | undefined => {
+  let acc: TranslationObject | string | undefined = obj;
+  for (let i = 0; i < key.split(".").length; i++) {
+    const part = key.split(".")[i];
+    if (acc && typeof acc !== "string" && acc[part] !== undefined) {
+      acc = acc[part];
+    } else {
+      acc = undefined; // If a part is not found, stop and return undefined
+      break;
+    }
+  }
+  return typeof acc === "string" ? acc : undefined;
+};
+
+async function fetchTranslations(lang: string) {
+  try {
+    const translations = await import(`@/i18n/translations.${lang}.json`);
+    return translations;
+  } catch {
+    console.warn(`Translation file for language "${lang}" not found.`);
+    return null;
+  }
+}
+
+export function useTranslation() {
+  const [translations, setTranslations] =
+    useState<TranslationObject>(defaultTranslations);
+
+  useEffect(() => {
+    const fetchLanguageAndTranslations = async () => {
+      const lang = await getAvailableLanguage();
+      const loadedTranslations = await fetchTranslations(lang);
+      setTranslations(loadedTranslations || defaultTranslations);
+    };
+
+    fetchLanguageAndTranslations();
+
+    const handleLocalStorageLangChange = () => fetchLanguageAndTranslations();
+
+    window.addEventListener(
+      "localStorageLangChange",
+      handleLocalStorageLangChange,
+    );
+    return () =>
+      window.removeEventListener(
+        "localStorageLangChange",
+        handleLocalStorageLangChange,
+      );
+  }, []);
+
+  const t = useCallback(
+    (translationKey: string): string => {
+      return (
+        getNestedTranslation(translations, translationKey) ||
+        getNestedTranslation(defaultTranslations, translationKey) ||
+        translationKey
+      );
+    },
+    [translations],
+  );
+
+  return { t };
+}
+
+interface TransProps {
+  i18nKey: string; // Translation key
+  values?: Record<string, React.ReactNode>; // Placeholder values
+  components?: React.ReactNode[]; // Components to inject into placeholders
+}
+
+export const Trans: React.FC<TransProps> = ({
+  i18nKey,
+  values = {},
+  components = [],
+}) => {
+  const { t } = useTranslation();
+  const translation = t(i18nKey);
+
+  const renderWithPlaceholders = (template: string) => {
+    const parts = template.split(/({{.*?}})/g); // Split on placeholders like {{key}}
+    return parts.map((part, index) => {
+      const match = part.match(/{{(.*?)}}/); // Match placeholders
+      if (match) {
+        const placeholderKey = match[1];
+        if (placeholderKey in values) {
+          const value = values[placeholderKey];
+          return typeof value === "string" ? (
+            <React.Fragment key={index}>{value}</React.Fragment>
+          ) : (
+            value
+          );
+        } else {
+          const component = components.find(
+            (comp) => React.isValidElement(comp) && comp.key === placeholderKey,
+          );
+          if (component) {
+            return component;
+          }
+        }
+      }
+      return part; // Return plain text if no placeholder
+    });
+  };
+
+  return <>{renderWithPlaceholders(translation)}</>;
+};
+
+export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
+  className,
+}) => {
+  const [selectedLang, setSelectedLang] = useState<string>("");
+
+  useEffect(() => {
+    // Fetch and set the selected language
+    const fetchLanguage = async () => {
+      const lang = await getAvailableLanguage();
+      setSelectedLang(lang);
+    };
+
+    fetchLanguage(); // Set the language initially
+  }, []);
+
+  const handleLanguageChange = (
+    event: React.ChangeEvent<HTMLSelectElement>,
+  ) => {
+    const newLang = event.target.value;
+    setSelectedLang(newLang);
+    window.localStorage.setItem("next-export-i18n-lang", newLang);
+
+    // Dispatch an event to notify other components or contexts of the language change
+    const langChangeEvent = new Event("localStorageLangChange");
+    window.dispatchEvent(langChangeEvent);
+  };
+
+  return (
+    <select
+      className={`${classes.languageSelector} ${className || ""}`}
+      value={selectedLang}
+      onChange={handleLanguageChange}
+    >
+      {Object.entries(languages).map(([langCode, langName]) => (
+        <option key={langCode} value={langCode}>
+          {langName}
+        </option>
+      ))}
+    </select>
+  );
+};
diff --git a/src/components/footer.tsx b/src/components/footer.tsx
index 3c5c7eef..d119323b 100644
--- a/src/components/footer.tsx
+++ b/src/components/footer.tsx
@@ -1,5 +1,8 @@
+"use client";
+
 import { Container, Group, ActionIcon, rem, Text } from "@mantine/core";
 import Link from "next/link";
+import { useTranslation } from "@/app/translate";
 
 import {
   IconBrandX,
@@ -16,36 +19,37 @@ const allSocials = [
   {
     type: IconBrandGithub,
     url: "https://github.com/ruffle-rs",
-    title: "GitHub",
+    titleKey: "footer.github",
   },
   {
     type: IconBrandX,
     url: "https://twitter.com/ruffle_rs",
-    title: "X",
+    titleKey: "footer.social-x",
   },
   {
     type: IconBrandTiktok,
     url: "https://www.tiktok.com/@ruffle.rs",
-    title: "Tiktok",
+    titleKey: "footer.tiktok",
   },
   {
     type: IconBrandInstagram,
     url: "https://www.instagram.com/ruffle.rs/",
-    title: "Instagram",
+    titleKey: "footer.instagram",
   },
   {
     type: IconBrandMastodon,
     url: "https://mastodon.gamedev.place/@ruffle",
-    title: "Mastodon",
+    titleKey: "footer.mastodon",
   },
   {
     type: IconBrandDiscord,
     url: "https://discord.gg/ruffle",
-    title: "Discord",
+    titleKey: "footer.discord",
   },
 ];
 
 export function FooterSocial() {
+  const { t } = useTranslation();
   const socials = allSocials.map((social, i) => (
     <ActionIcon
       key={i}
@@ -55,7 +59,7 @@ export function FooterSocial() {
       component={Link}
       href={social.url}
       target="_blank"
-      title={social.title}
+      title={t(social.titleKey)}
     >
       <social.type style={{ width: rem(18), height: rem(18) }} stroke={1.5} />
     </ActionIcon>
@@ -73,7 +77,7 @@ export function FooterSocial() {
             priority
           />
           <Text size="lg" className={classes.tagline}>
-            Putting Flash back on the web
+            {t("footer.tagline")}
           </Text>
         </Container>
         <Group
diff --git a/src/components/header.module.css b/src/components/header.module.css
index df9d3a36..c42c3a9d 100644
--- a/src/components/header.module.css
+++ b/src/components/header.module.css
@@ -34,6 +34,12 @@
   }
 }
 
+.hiddenOnDesktop {
+  @media (min-width: $mantine-breakpoint-md) {
+    display: none;
+  }
+}
+
 .burger {
   --burger-color: var(--ruffle-orange);
 }
@@ -49,3 +55,10 @@
 .overlay {
   top: rem(56px);
 }
+
+.languageSelector {
+  color: var(--ruffle-orange);
+  background: var(--ruffle-blue);
+  border: 1px solid var(--ruffle-orange);
+  padding: 4px;
+}
diff --git a/src/components/header.tsx b/src/components/header.tsx
index 112fb98d..9c675aa3 100644
--- a/src/components/header.tsx
+++ b/src/components/header.tsx
@@ -6,29 +6,36 @@ import classes from "./header.module.css";
 import Image from "next/image";
 import Link from "next/link";
 import { usePathname } from "next/navigation";
+import { LanguageSelector, useTranslation } from "@/app/translate";
+import React from "react";
 
 const links = [
-  { link: "/", label: "About Ruffle" },
-  { link: "/downloads", label: "Downloads" },
-  { link: "/compatibility", label: "Compatibility" },
-  { link: "/contribute", label: "Get Involved" },
-  { link: "/blog", label: "Blog" },
-  { link: "/demo", label: "Demo", target: "_blank" },
-  { link: "https://discord.gg/ruffle", label: "Discord", target: "_blank" },
+  { link: "/", labelKey: "header.about" },
+  { link: "/downloads", labelKey: "header.downloads" },
+  { link: "/compatibility", labelKey: "header.compatibility" },
+  { link: "/contribute", labelKey: "header.contribute" },
+  { link: "/blog", labelKey: "header.blog" },
+  { link: "/demo", labelKey: "header.demo", target: "_blank" },
+  {
+    link: "https://discord.gg/ruffle",
+    labelKey: "header.discord",
+    target: "_blank",
+  },
   {
     link: "https://github.com/ruffle-rs/ruffle/",
-    label: "GitHub",
+    labelKey: "header.github",
     target: "_blank",
   },
 ];
 
 export function Header() {
+  const { t } = useTranslation();
   const [opened, { toggle, close }] = useDisclosure(false);
   const pathname = usePathname();
 
   const items = links.map((link) => (
     <Link
-      key={link.label}
+      key={link.labelKey}
       href={link.link}
       target={link.target}
       className={classes.link}
@@ -37,13 +44,13 @@ export function Header() {
         close();
       }}
     >
-      {link.label}
+      {t(link.labelKey)}
     </Link>
   ));
 
   return (
     <header className={classes.header}>
-      <Container size="md" className={classes.inner}>
+      <Container size="lg" className={classes.inner}>
         <Link href="/">
           <Image
             src="/logo.svg"
@@ -55,6 +62,7 @@ export function Header() {
         </Link>
         <Group gap={5} visibleFrom="md">
           {items}
+          <LanguageSelector />
         </Group>{" "}
         <Drawer
           opened={opened}
@@ -69,6 +77,7 @@ export function Header() {
         >
           {items}
         </Drawer>
+        <LanguageSelector className={classes.hiddenOnDesktop} />
         <Burger
           opened={opened}
           onClick={toggle}
diff --git a/src/components/logo.tsx b/src/components/logo.tsx
index b950a93c..d51139cb 100644
--- a/src/components/logo.tsx
+++ b/src/components/logo.tsx
@@ -1,9 +1,10 @@
 "use client";
 
-import React from "react";
+import React, { useEffect, useRef, useState } from "react";
 import Image from "next/image";
 import Script from "next/script";
 import classes from "../app/index.module.css";
+import { useTranslation } from "@/app/translate";
 
 declare global {
   interface Window {
@@ -27,44 +28,29 @@ interface LogoProps {
   className?: string;
 }
 
-interface LogoState {
-  player: RufflePlayer | null;
-}
-
-export default class InteractiveLogo extends React.Component<
-  LogoProps,
-  LogoState
-> {
-  private readonly container: React.RefObject<HTMLDivElement>;
-  private player: RufflePlayer | null = null;
+export default function InteractiveLogo({ className }: LogoProps) {
+  const { t } = useTranslation();
+  const container = useRef<HTMLDivElement>(null);
+  const [player, setPlayer] = useState<RufflePlayer | null>(null);
 
-  constructor(props: LogoProps) {
-    super(props);
-
-    this.container = React.createRef();
-    this.state = {
-      player: null,
-    };
-  }
+  const removeRufflePlayer = () => {
+    player?.remove();
+    setPlayer(null);
+  };
 
-  private removeRufflePlayer() {
-    this.player?.remove();
-    this.player = null;
-    this.setState({ player: null });
-  }
-
-  private load() {
-    if (this.player) {
-      // Already loaded.
+  const loadPlayer = () => {
+    if (player) {
       return;
     }
 
-    this.player = (window.RufflePlayer as PublicAPI)?.newest()?.createPlayer();
+    const rufflePlayer = (window.RufflePlayer as PublicAPI)
+      ?.newest()
+      ?.createPlayer();
 
-    if (this.player) {
-      this.container.current!.appendChild(this.player);
+    if (rufflePlayer) {
+      container.current!.appendChild(rufflePlayer);
 
-      this.player
+      rufflePlayer
         .load({
           url: "/logo-anim.swf",
           autoplay: "on",
@@ -75,39 +61,33 @@ export default class InteractiveLogo extends React.Component<
           preferredRenderer: "canvas",
         })
         .catch(() => {
-          this.removeRufflePlayer();
+          removeRufflePlayer();
         });
-      this.player.style.width = "100%";
-      this.player.style.height = "100%";
-      this.setState({ player: this.player });
+      rufflePlayer.style.width = "100%";
+      rufflePlayer.style.height = "100%";
+      setPlayer(rufflePlayer);
     }
-  }
-
-  componentDidMount() {
-    this.load();
-  }
-
-  componentWillUnmount() {
-    this.removeRufflePlayer();
-  }
-
-  render() {
-    return (
-      <>
-        <Script
-          src="https://unpkg.com/@ruffle-rs/ruffle"
-          onReady={() => this.load()}
+  };
+
+  useEffect(() => {
+    return () => removeRufflePlayer();
+  }, []);
+
+  return (
+    <>
+      <Script
+        src="https://unpkg.com/@ruffle-rs/ruffle"
+        onReady={() => loadPlayer()}
+      />
+      <div ref={container} className={className}>
+        <Image
+          src="/logo.svg"
+          alt={t("logo.alt-tag")}
+          className={player ? classes.hidden : classes.staticLogo}
+          width="340"
+          height="110"
         />
-        <div ref={this.container} className={this.props.className}>
-          <Image
-            src="/logo.svg"
-            alt="Ruffle Logo"
-            className={this.state.player ? classes.hidden : classes.staticLogo}
-            width="340"
-            height="110"
-          />
-        </div>
-      </>
-    );
-  }
+      </div>
+    </>
+  );
 }
diff --git a/src/i18n/translations.en.json b/src/i18n/translations.en.json
new file mode 100644
index 00000000..8310ae78
--- /dev/null
+++ b/src/i18n/translations.en.json
@@ -0,0 +1,146 @@
+{
+  "home": {
+    "title": "An open source Flash Player emulator",
+    "intro": "Made to run natively on all modern operating systems and browsers, Ruffle brings Flash content back to life with no extra fuss.",
+    "safe": "Safe to use",
+    "safe-description": "Using the guarantees of Rust and WASM, we avoid the security pitfalls Flash was known for.",
+    "easy": "Easy to install",
+    "easy-description": "Whether you're a user or a website owner, we've made it as easy as possible to get up and running.",
+    "free": "Free and open source",
+    "free-description": "Licensed MIT/Apache 2.0, you're free to use Ruffle how you please!"
+  },
+  "header": {
+    "about": "About Ruffle",
+    "downloads": "Downloads",
+    "compatibility": "Compatibility",
+    "contribute": "Get Involved",
+    "blog": "Blog",
+    "demo": "Demo",
+    "discord": "Discord",
+    "github": "GitHub"
+  },
+  "footer": {
+    "github": "GitHub",
+    "social-x": "X",
+    "tiktok": "TikTok",
+    "instagram": "Instagram",
+    "mastodon": "Mastodon",
+    "discord": "Discord",
+    "tagline": "Putting Flash back on the web"
+  },
+  "logo": {
+    "alt-tag": "Ruffle Logo"
+  },
+  "404": {
+    "not-found": "Page not found",
+    "not-found-description": "The requested page could not be found."
+  },
+  "installers": {
+    "windows-64-short-name": "Windows (64-bit)",
+    "windows-long-name": "Windows Executable",
+    "windows-32-short-name": "Windows (32-bit)",
+    "macos-short-name": "macOS",
+    "macos-long-name": "Mac Application",
+    "flatpak-short-name": "Flatpak",
+    "flatpak-long-name": "Flatpak App",
+    "linux-short-name": "Linux",
+    "linux-long-name": "Linux Executable",
+    "chrome-short-name": "Chrome",
+    "chrome-long-name": "Chrome Extension",
+    "edge-short-name": "Edge",
+    "edge-long-name": "Edge Extension",
+    "firefox-short-name": "Firefox",
+    "firefox-long-name": "Firefox Extension",
+    "safari-short-name": "Safari",
+    "safari-long-name": "Safari Extension",
+    "selfhosted-short-name": "Self Hosted",
+    "selfhosted-long-name": "Website Package",
+    "other-downloads": "Other Downloads"
+  },
+  "downloads": {
+    "version": "Version",
+    "desktop-app": "Desktop Application",
+    "desktop-app-description": "If you want to run Flash content on your computer without a browser in-between, we have native applications that will take full advantage of your GPU and system resources to get those extra frames when playing intense games.",
+    "browser-extension": "Browser Extension",
+    "browser-extension-description": "If you visit websites that have Flash content but aren't using Ruffle, or you want to ensure you're using the latest and greatest version of Ruffle on every website, then our browser extension is the perfect thing for you!",
+    "nightly-releases": "Nightly Releases",
+    "nightly-releases-description": "If none of the above are suitable for you, you can manually download the latest Nightly release. These are automatically built every day (approximately midnight UTC), unless there are no changes on that day. Older nightly releases are available on {{link}}.",
+    "web-package": "Web Package",
+    "web-package-description": "You can install Ruffle onto a website using one single line of code by using a CDN, no extra work required! It'll always stay up to date with the latest available version of Ruffle.",
+    "self-host-description": "If you'd like to host it yourself, you can grab {{link}} and upload it to your server. Then, include it on your page like so:",
+    "self-host-description-link": "the latest self-hosted package",
+    "advanced-usage-description": "For advanced usage, consult {{link}} for our JavaScript API and installation options.",
+    "advanced-usage-description-link": "our documentation",
+    "chrome-extension-alt": "Available in the Chrome Web Store",
+    "firefox-extension-alt": "Get the Add-On for Firefox",
+    "edge-extension-alt": "Get it from Microsoft for Edge"
+  },
+  "compatibility": {
+    "title": "ActionScript Compatibility",
+    "description": "The biggest factor in content compatibility is ActionScript; the language that powers interactivity in games and applications made with Flash. All Flash content falls in one of two categories, depending on which version of the language was used to create it.",
+    "tracking": "We track our progress in each AVM by splitting them up into two different areas:",
+    "language-description": "The {{language}} is the underlying virtual machine itself and the language concepts that it understands, like variables and classes and how they all interact together.",
+    "language": "Language",
+    "api-description": "The {{api}} is the underlying virtual machine itself and the language concepts that it understands, like variables and classes and how they all interact together.",
+    "api": "API",
+    "avm1-title": "AVM 1: ActionScript 1 & 2",
+    "avm1-description": "AVM 1 is the original ActionScript Virtual Machine. All movies made before Flash Player 9 (June 2006) will be made with AVM 1, and it remained supported & available to authors until the release of Flash Professional CC (2013), after which point content started moving to AVM 2.",
+    "avm1-support": "We believe that most AVM 1 content will work, but we are aware of some graphical inaccuracies and smaller bugs here and there. Please feel free to report any issues you find that are not present in the original Flash Player!",
+    "avm2-title": "AVM 2: ActionScript 3",
+    "avm2-description": "AVM 2 was introduced with Flash Player 9 (June 2006), to replace the earlier AVM 1. After the release of Flash Professional CC (2013), authors are required to use ActionScript 3 - making any movie made after that date very likely to fall under this category.",
+    "avm2-support": "Ruffle now has decent support for AVM 2, and it's our experience that most games will work well enough to be played. We're still rapidly improving in this area though, so bug reports about any broken content are always welcome!",
+    "weekly-contributions": "Weekly Contributions",
+    "done": "done",
+    "partial": "partially done",
+    "more": "More Info",
+    "commits-description": "{{commitNumber}} commits on the week of {{week}}",
+    "avm2": {
+      "title": "ActionScript 3 API Progress",
+      "description": "ActionScript 3 contains many different methods and classes - not all of which is ultimately useful to every application. The majority of content only uses a small portion of the available API, so even if we aren't 100% \"complete\" across the entirely of AVM 2, we may have enough for that content to run completely fine.",
+      "classification": "On this page, we list every single ActionScript 3 API that exists but Ruffle does not yet 100% implement. We classify items into three different stages:",
+      "implemented": "Implemented",
+      "implemented-description": "{{implemented}} items are marked as \"Done\", and we believe they are fully functional. For brevity, we do not list completed items on this page.",
+      "partial": "Partial",
+      "partial-description": "{{partial}} items exist and are enough for most content to work, but are incomplete. A partial class may be missing items, or a method may just simply return a value without performing its intended function.",
+      "missing": "Missing",
+      "missing-description": "{{missing}} items do not exist at all in Ruffle yet, and trying to use them will give an error.",
+      "tree": "You can also visualize the progress {{link}}.",
+      "tree-link": "as a tree graph",
+      "top-level": "(Top Level)",
+      "package-level": "(Package Level)",
+      "hide": "Hide",
+      "show": "Show",
+      "missing-members": "Missing Members",
+      "done": "Done"
+    }
+  },
+  "contribute": {
+    "involved": "Get Involved",
+    "involved-description": "Ruffle is an entirely open source project, maintained by volunteers like you who just want to help preserve a slice of history. We rely on contributions of any kind to keep this project going, and absolutely would not have come as far as we have without the amazing support of our community who came together to make Ruffle happen. If you'd like to join them, there are many ways to help make Ruffle better than ever!",
+    "code": "Contribute code",
+    "code-description": "There's a few different codebases in couple of different languages, and we'd welcome any help to try and maintain and improve them.",
+    "rust": "The actual {{emulator}} itself, and all of the {{desktop-player}}, is written in Rust.",
+    "emulator": "emulator",
+    "desktop-player": "desktop player",
+    "typescript": "The {{web-player}}, the {{extension}} and our {{website}} is written in TypeScript.",
+    "web-player": "web player",
+    "extension": "extension",
+    "website": "website",
+    "getting-started": "Check out our {{guidelines}} for information on how to start, and come join our {{discord}} if you need help!",
+    "guidelines": "Contributing Guidelines",
+    "test": "Test content",
+    "test-description": "Arguably more important than contributing code is testing Ruffle out. Go install Ruffle and try out your favourite games and animations. Look for any difference from the official Flash Player, and report your findings to us.",
+    "report-bugs": "If you find any bugs, changes of behaviour, performance issues or any visual differences then please report those to our {{bug-tracker}}.",
+    "bug-tracker": "our bug tracker",
+    "working": "If it runs flawlessly, come share the good news on {{our-discord}}!",
+    "our-discord": "our Discord",
+    "sponsor": "Sponsor the project",
+    "sponsor-description": "If you are able and willing to, we welcome any and all financial support to help us fund the project going forward. With your help, we can afford to spend more time dedicated to Ruffle, as well as pay for expenses such as build servers & hosting. We accept donations and sponsorships of any kind, big or small, through Open Source Collective 501(c)(6).",
+    "sponsor-info": "For more information, or to view the options available for sponsoring the project, please visit {{opencollective}}.",
+    "opencollective": "our Open Collective page",
+    "spread-the-word": "Spread the word!",
+    "spread-the-word-description": "Is your favourite Flash-based site shutting down? Let them know they can add one JavaScript file and keep it running! Feeling nostalgic for some old Flash games? Go play some on Newgrounds with Ruffle installed, and tell your friends about it! Maybe you're a streamer and looking for some silly content? There's literally decades worth, now unlocked and accessible once more.",
+    "diamond": "Diamond Sponsors",
+    "diamond-description": "We'd like to thank all of our sponsors, who help make this project possible. Below are our Diamond level sponsors, without whom we would not be here. Thank you."
+  }
+}