openreader/src/app/layout.tsx
Richard R 3c08b9e406 feat(shared): centralize origin validation logic and update metadata usage
Move origin extraction to shared utility for consistent validation across
server and client modules. Update layout metadata to use the shared origin
helper, improving maintainability and deployment flexibility. Add unit tests
for layout metadata to ensure correct origin handling.
2026-05-31 16:41:47 -06:00

83 lines
2.8 KiB
TypeScript

import "./globals.css";
import { ReactNode } from "react";
import { Metadata, Viewport } from "next";
import { Figtree } from "next/font/google";
import { ConsentAwareAnalytics } from "@/components/ConsentAwareAnalytics";
import { CookieConsentBanner } from "@/components/CookieConsentBanner";
import { getResolvedRuntimeConfig } from "@/lib/server/runtime-config";
import { tryGetOrigin } from "@/lib/shared/urls";
import pkg from "../../package.json";
const figtree = Figtree({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700", "800"],
display: "swap",
variable: "--font-display",
});
const themeInitScript = `
(() => {
const themes = ['system', 'light', 'dark', 'ocean', 'forest', 'sunset', 'sea', 'mint', 'lavender', 'rose', 'sand', 'sky', 'slate'];
const lightThemes = new Set(['light', 'lavender', 'rose', 'sand', 'sky', 'slate']);
const root = document.documentElement;
const stored = localStorage.getItem('theme');
const selected = stored && themes.includes(stored) ? stored : 'system';
const effective = selected === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: selected;
root.classList.remove(...themes);
root.classList.add(effective);
root.style.colorScheme = lightThemes.has(effective) ? 'light' : 'dark';
})();
`;
export const viewport: Viewport = {
viewportFit: "cover",
};
const defaultOrigin = "https://openreader.richardr.dev";
const validatedOrigin = tryGetOrigin(process.env.BASE_URL) || defaultOrigin;
export const metadata: Metadata = {
title: {
default: "OpenReader",
template: "%s | OpenReader",
},
manifest: "/manifest.json",
metadataBase: new URL(validatedOrigin),
verification: {
google: "MJXyTudn1kgQF8EtGD-tsnAWev7Iawso9hEvqeGHB3U",
},
};
function jsonEmbedSafe(value: unknown): string {
// Prevent `</script>` and U+2028/U+2029 from breaking the inline script.
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
export default async function RootLayout({ children }: { children: ReactNode }) {
const runtimeConfig = await getResolvedRuntimeConfig();
const runtimeConfigWithAppVersion = {
...runtimeConfig,
appVersion: pkg.version,
};
const runtimeConfigInit = `window.__RUNTIME_CONFIG__=${jsonEmbedSafe(runtimeConfigWithAppVersion)};`;
return (
<html lang="en" className={figtree.variable} suppressHydrationWarning>
<head>
<meta name="color-scheme" content="light dark" />
<script dangerouslySetInnerHTML={{ __html: runtimeConfigInit }} />
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="antialiased">
{children}
<CookieConsentBanner />
<ConsentAwareAnalytics />
</body>
</html>
);
}