From 51c121183274140ccb92294867625b018693c959 Mon Sep 17 00:00:00 2001 From: shigahi Date: Fri, 23 Jan 2026 10:25:43 +0100 Subject: [PATCH] Add hreflang support and dynamic OG image generation - Issue #35: Add hreflang tags for multilingual SEO - Add I18nOptions interface with enabled flag and defaultLocale - Add PageAlternate interface for alternate language versions - Generate hreflang link tags in HeadRewriter for self-referencing, alternates, and x-default - Add UI in Advanced Settings for i18n configuration - Add per-page alternate language configuration in Page SEO section - Issue #36: Add dynamic OG image generation - Add OgImageGenerationOptions interface with customizable colors/font - Generate SVG-based OG images at /og-image/[slug] endpoint - Auto-inject generated OG image meta tags when no custom image is set - Add UI in Advanced Settings for OG image customization - Images are cached for 1 week for performance --- src/App.tsx | 266 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/code.ts | 103 ++++++++++++++++++++ 2 files changed, 369 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index e49c8d8..37a02ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,6 +47,7 @@ import code, { CodeData, ImageOptions, PageMetadata, + PageAlternate, StructuredDataOptions, BrandingOptions, SocialPreviewOptions, @@ -58,6 +59,8 @@ import code, { SubdomainRedirect, RedirectRule, RssOptions, + I18nOptions, + OgImageGenerationOptions, } from "./code"; import "./styles.css"; @@ -203,6 +206,17 @@ export default function App() { description: "", language: "en-us", }); + const [i18n, setI18n] = useState({ + enabled: false, + defaultLocale: "en", + }); + const [ogImageGeneration, setOgImageGeneration] = + useState({ + enabled: false, + backgroundColor: "#1a1a2e", + textColor: "#ffffff", + fontSize: 64, + }); function createInputHandler( setter: React.Dispatch>, @@ -304,6 +318,55 @@ export default function App() { const handleCustomHtmlChange = createFieldHandler(setCustomHtml, customHtml); const handleCustom404Change = createFieldHandler(setCustom404, custom404); const handleRssChange = createFieldHandler(setRss, rss); + const handleI18nChange = createFieldHandler(setI18n, i18n); + const handleOgImageGenerationChange = createFieldHandler( + setOgImageGeneration, + ogImageGeneration, + ); + + function handlePageAlternate( + slug: string, + index: number, + field: keyof PageAlternate, + value: string, + ): void { + const currentAlternates = pageMetadata[slug]?.alternates || []; + const updatedAlternates = currentAlternates.map((alt, i) => + i === index ? { ...alt, [field]: value } : alt, + ); + setPageMetadata({ + ...pageMetadata, + [slug]: { + ...pageMetadata[slug], + alternates: updatedAlternates, + }, + }); + setCopied(false); + } + + function addPageAlternate(slug: string): void { + const currentAlternates = pageMetadata[slug]?.alternates || []; + setPageMetadata({ + ...pageMetadata, + [slug]: { + ...pageMetadata[slug], + alternates: [...currentAlternates, { locale: "", slug: "" }], + }, + }); + setCopied(false); + } + + function deletePageAlternate(slug: string, index: number): void { + const currentAlternates = pageMetadata[slug]?.alternates || []; + setPageMetadata({ + ...pageMetadata, + [slug]: { + ...pageMetadata[slug], + alternates: currentAlternates.filter((_, i) => i !== index), + }, + }); + setCopied(false); + } function addSubdomainRedirect(): void { setSubdomainRedirects([ @@ -425,6 +488,8 @@ export default function App() { subdomainRedirects, redirectRules, rss, + i18n, + ogImageGeneration, }; const script = noError ? code(codeData) : undefined; @@ -754,6 +819,76 @@ export default function App() { variant="outlined" size="small" /> + {i18n.enabled && ( + + + Alternate Language Versions (hreflang) + + {(pageMetadata[customUrl]?.alternates || []).map( + (alt, altIndex) => ( + + + handlePageAlternate( + customUrl, + altIndex, + "locale", + e.target.value, + ) + } + variant="outlined" + size="small" + sx={{ width: "100px" }} + /> + + handlePageAlternate( + customUrl, + altIndex, + "slug", + e.target.value, + ) + } + variant="outlined" + size="small" + sx={{ flex: 1 }} + /> + + + ), + )} + + + )} @@ -1508,6 +1643,137 @@ export default function App() { + + + + + + Internationalization (i18n) + + + Add hreflang tags for multilingual SEO + + + + handleI18nChange("enabled", e.target.checked) + } + /> + + + + + handleI18nChange("defaultLocale", e.target.value) + } + value={i18n.defaultLocale} + variant="outlined" + size="small" + /> + + Configure alternate language versions in each page's SEO + settings. Hreflang tags help search engines serve the + correct language version to users. + + + + + + + + + + Auto-Generate OG Images + + + Create Open Graph images from page titles + + + + handleOgImageGenerationChange("enabled", e.target.checked) + } + /> + + + + + + handleOgImageGenerationChange( + "backgroundColor", + e.target.value, + ) + } + value={ogImageGeneration.backgroundColor} + variant="outlined" + size="small" + sx={{ flex: 1 }} + /> + + handleOgImageGenerationChange( + "textColor", + e.target.value, + ) + } + value={ogImageGeneration.textColor} + variant="outlined" + size="small" + sx={{ flex: 1 }} + /> + + handleOgImageGenerationChange( + "fontSize", + Number(e.target.value), + ) + } + value={ogImageGeneration.fontSize} + variant="outlined" + size="small" + sx={{ width: "120px" }} + /> + + + Auto-generated OG images are used when no custom image is + set. Images are served as SVG at /og-image/[slug]. + + + + diff --git a/src/code.ts b/src/code.ts index 7e75aa5..b2150aa 100644 --- a/src/code.ts +++ b/src/code.ts @@ -10,10 +10,17 @@ export interface ImageOptions { imageMetadata?: string; } +export interface PageAlternate { + locale: string; + slug: string; + url?: string; +} + export interface PageMetadata { title?: string; description?: string; ogImage?: string; + alternates?: PageAlternate[]; } export interface StructuredDataOptions { @@ -80,6 +87,18 @@ export interface RssOptions { language?: string; } +export interface I18nOptions { + enabled: boolean; + defaultLocale: string; +} + +export interface OgImageGenerationOptions { + enabled: boolean; + backgroundColor?: string; + textColor?: string; + fontSize?: number; +} + export interface CodeData { myDomain: string; notionUrl: string; @@ -102,6 +121,8 @@ export interface CodeData { subdomainRedirects: SubdomainRedirect[]; redirectRules: RedirectRule[]; rss: RssOptions; + i18n: I18nOptions; + ogImageGeneration: OgImageGenerationOptions; } function getId(url: string): string { @@ -137,6 +158,8 @@ export default function code(data: CodeData): string { subdomainRedirects, redirectRules, rss, + i18n, + ogImageGeneration, } = data; let url = myDomain.replace("https://", "").replace("http://", ""); if (url.slice(-1) === "/") url = url.slice(0, url.length - 1); @@ -269,6 +292,22 @@ ${ const RSS_DESCRIPTION = '${rss?.description || ""}'; const RSS_LANGUAGE = '${rss?.language || "en-us"}'; + /* + * Step 3.10: Internationalization (i18n) configuration (optional) + * Add hreflang tags for multilingual SEO + */ + const I18N_ENABLED = ${i18n?.enabled || false}; + const DEFAULT_LOCALE = '${i18n?.defaultLocale || "en"}'; + + /* + * Step 3.11: Dynamic OG Image Generation (optional) + * Auto-generate Open Graph images from page titles + */ + const OG_IMAGE_GENERATION_ENABLED = ${ogImageGeneration?.enabled || false}; + const OG_IMAGE_BG_COLOR = '${ogImageGeneration?.backgroundColor || "#1a1a2e"}'; + const OG_IMAGE_TEXT_COLOR = '${ogImageGeneration?.textColor || "#ffffff"}'; + const OG_IMAGE_FONT_SIZE = ${ogImageGeneration?.fontSize || 64}; + /* Step 4: enter a Google Font name, you can choose from https://fonts.google.com */ const GOOGLE_FONT = '${googleFont || ""}'; @@ -405,6 +444,40 @@ ${ .replace(/'/g, '''); } + function escapeHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function generateOgImage(slug) { + const metadata = PAGE_METADATA[slug] || {}; + const title = metadata.title || PAGE_TITLE || MY_DOMAIN; + const siteName = SITE_NAME || MY_DOMAIN; + const escapedTitle = escapeHtml(title); + const escapedSiteName = escapeHtml(siteName); + + const svg = \` + + + \${escapedTitle.length > 40 ? escapedTitle.substring(0, 40) + '...' : escapedTitle} + + + \${escapedSiteName} + + \`; + + return new Response(svg, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'public, max-age=604800', + }, + }); + } + const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, OPTIONS', @@ -494,6 +567,11 @@ ${ response.headers.set('content-type', 'application/rss+xml; charset=utf-8'); return response; } + // Handle dynamic OG image generation (Issue #36) + if (url.pathname.startsWith('/og-image/') && OG_IMAGE_GENERATION_ENABLED) { + const slug = url.pathname.replace('/og-image/', ''); + return generateOgImage(slug); + } let response; if (url.pathname.startsWith('/app') && url.pathname.endsWith('js')) { response = await fetch(url.toString()); @@ -768,6 +846,31 @@ ${ element.append(\`\`, { html: true }); } + // Add auto-generated OG image if no custom image is set (Issue #36) + if (!effectiveOgImage && OG_IMAGE_GENERATION_ENABLED) { + const generatedUrl = \`https://\${MY_DOMAIN}/og-image/\${this.slug}\`; + element.append(\`\`, { html: true }); + element.append(\`\`, { html: true }); + element.append(\`\`, { html: true }); + element.append(\`\`, { html: true }); + } + + // Add hreflang tags for multilingual SEO (Issue #35) + if (I18N_ENABLED) { + // Self-referencing hreflang for current page + element.append(\`\`, { html: true }); + + // Add alternate language versions from page metadata + const pageAlternates = this.metadata.alternates || []; + for (const alt of pageAlternates) { + const href = alt.url || \`https://\${MY_DOMAIN}\${alt.slug}\`; + element.append(\`\`, { html: true }); + } + + // x-default for language/region selector pages + element.append(\`\`, { html: true }); + } + // Add AI crawler attribution meta tags (Issue #13) if (AI_ATTRIBUTION !== '') { element.append(\`\`, { html: true });