velxio/frontend/src/utils/useSEO.ts

89 lines
3.2 KiB
TypeScript
Raw Normal View History

import { useEffect, useRef } from 'react';
export interface SEOMeta {
title: string;
description: string;
url: string;
ogImage?: string;
/** Module-level constant: injected once on mount, removed on unmount. */
jsonLd?: object | object[];
}
function qs(selector: string): HTMLMetaElement | null {
return document.querySelector(selector) as HTMLMetaElement | null;
}
/**
* Updates document.title, meta description, OG/Twitter tags, and canonical
* to reflect the current page. Restores originals on unmount.
*
* jsonLd (if provided) is injected as a <script type="application/ld+json">
* once on mount and removed on unmount. Pass a module-level constant to avoid
* unnecessary re-injection.
*/
export function useSEO({ title, description, url, ogImage, jsonLd }: SEOMeta) {
const scriptRef = useRef<HTMLScriptElement | null>(null);
useEffect(() => {
const origTitle = document.title;
const descEl = qs('meta[name="description"]');
const ogTitleEl = qs('meta[property="og:title"]');
const ogDescEl = qs('meta[property="og:description"]');
const ogUrlEl = qs('meta[property="og:url"]');
const ogImgEl = qs('meta[property="og:image"]');
const twTitleEl = qs('meta[name="twitter:title"]');
const twDescEl = qs('meta[name="twitter:description"]');
const canonicalEl = document.querySelector('link[rel="canonical"]') as HTMLLinkElement | null;
const get = (el: HTMLMetaElement | null) => el?.getAttribute('content') ?? '';
const set = (el: HTMLMetaElement | null, v: string) => el?.setAttribute('content', v);
const origDesc = get(descEl);
const origOgTitle = get(ogTitleEl);
const origOgDesc = get(ogDescEl);
const origOgUrl = get(ogUrlEl);
const origOgImg = get(ogImgEl);
const origTwTitle = get(twTitleEl);
const origTwDesc = get(twDescEl);
const origCanonical = canonicalEl?.getAttribute('href') ?? '';
// Apply
document.title = title;
set(descEl, description);
set(ogTitleEl, title);
set(ogDescEl, description);
set(ogUrlEl, url);
if (ogImage) set(ogImgEl, ogImage);
set(twTitleEl, title);
set(twDescEl, description);
canonicalEl?.setAttribute('href', url);
// Inject JSON-LD once (module-level constants don't change)
if (jsonLd && !scriptRef.current) {
const script = document.createElement('script');
script.type = 'application/ld+json';
script.setAttribute('data-seo-page', '1');
script.textContent = JSON.stringify(Array.isArray(jsonLd) ? jsonLd : [jsonLd]);
document.head.appendChild(script);
scriptRef.current = script;
}
return () => {
document.title = origTitle;
set(descEl, origDesc);
set(ogTitleEl, origOgTitle);
set(ogDescEl, origOgDesc);
set(ogUrlEl, origOgUrl);
if (ogImage) set(ogImgEl, origOgImg);
set(twTitleEl, origTwTitle);
set(twDescEl, origTwDesc);
canonicalEl?.setAttribute('href', origCanonical);
if (scriptRef.current) {
document.head.removeChild(scriptRef.current);
scriptRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title, description, url, ogImage]);
}