UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

254 lines (233 loc) 7.85 kB
import { $inject, Alepha } from "alepha"; import type { Head, HeadMeta } from "../interfaces/Head.ts"; import { HeadProvider } from "./HeadProvider.ts"; /** * Browser-side head provider that manages document head elements. * * Used by ReactBrowserProvider and ReactBrowserRouterProvider to update * document title, meta tags, and other head elements during client-side * navigation. */ export class BrowserHeadProvider { protected readonly alepha = $inject(Alepha); protected readonly headProvider = $inject(HeadProvider); protected get document(): Document { return window.document; } /** * Fill head state from route configurations and render to document. * Combines fillHead from HeadProvider with renderHead to the DOM. * * Only runs in browser environment - no-op on server. */ public fillAndRenderHead(state: { head: Head; layers: Array<any> }): void { // Skip on server-side if (!this.alepha.isBrowser()) { return; } this.headProvider.fillHead(state as any); if (state.head) { this.renderHead(this.document, state.head); } } /** * Re-evaluate all global $head entries and apply the result to the DOM. * * Call this when something that affects global $head output changes at runtime * (e.g., theme switch). Page-level head (title, meta from routes) is not touched. */ public refreshGlobalHead(): void { const head = this.headProvider.resolveGlobal(); this.renderHead(this.document, head); } public getHead(document: Document): Head { return { get title() { return document.title; }, get htmlAttributes() { const attrs: Record<string, string> = {}; for (const attr of document.documentElement.attributes) { attrs[attr.name] = attr.value; } return attrs; }, get bodyAttributes() { const attrs: Record<string, string> = {}; for (const attr of document.body.attributes) { attrs[attr.name] = attr.value; } return attrs; }, get meta() { const metas: HeadMeta[] = []; // Get meta tags with name attribute for (const meta of document.head.querySelectorAll("meta[name]")) { const name = meta.getAttribute("name"); const content = meta.getAttribute("content"); if (name && content) { metas.push({ name, content }); } } // Get meta tags with property attribute (OpenGraph) for (const meta of document.head.querySelectorAll("meta[property]")) { const property = meta.getAttribute("property"); const content = meta.getAttribute("content"); if (property && content) { metas.push({ property, content }); } } return metas; }, }; } public renderHead(document: Document, head: Head): void { if (head.title) { document.title = head.title; } if (head.bodyAttributes) { for (const [key, value] of Object.entries(head.bodyAttributes)) { if (value) { document.body.setAttribute(key, value); } else { document.body.removeAttribute(key); } } } if (head.htmlAttributes) { for (const [key, value] of Object.entries(head.htmlAttributes)) { if (value) { document.documentElement.setAttribute(key, value); } else { document.documentElement.removeAttribute(key); } } } if (head.meta) { for (const it of head.meta) { this.renderMetaTag(document, it); } } if (head.link) { for (const it of head.link) { const { rel, href } = it; let link = document.querySelector(`link[rel="${rel}"][href="${href}"]`); if (!link) { link = document.createElement("link"); link.setAttribute("rel", rel); link.setAttribute("href", href); if (it.type) { link.setAttribute("type", it.type); } if (it.as) { link.setAttribute("as", it.as); } if (it.crossorigin != null) { link.setAttribute("crossorigin", ""); } if (it.media) { link.setAttribute("media", it.media); } if (it.sizes) { link.setAttribute("sizes", it.sizes); } if (it.hreflang) { link.setAttribute("hreflang", it.hreflang); } document.head.appendChild(link); } } } if (head.script) { for (const it of head.script) { this.renderScriptTag(document, it); } } } protected renderScriptTag( document: Document, script: | string | (Record<string, string | boolean | undefined> & { content?: string }), ): void { // Plain string → inline script. Dedupe by exact content match against // any existing inline script (handles SSR-emitted globals that would // otherwise be re-appended on hydration). if (typeof script === "string") { if (this.findInlineScriptByContent(document, script)) return; const el = document.createElement("script"); el.textContent = script; document.head.appendChild(el); return; } const { content, ...attrs } = script; // src-based scripts: dedupe by src attribute (existing behaviour). if (attrs.src) { if (document.querySelector(`script[src="${attrs.src}"]`)) return; } else if (typeof attrs.id === "string") { // id-based dedupe — single source of truth per id. if (document.querySelector(`script#${CSS.escape(attrs.id)}`)) return; } else if (content) { // Inline scripts with `content` and no src/id: fall back to content match. if (this.findInlineScriptByContent(document, content)) return; } const el = document.createElement("script"); for (const [key, value] of Object.entries(attrs)) { if (value === true) { el.setAttribute(key, ""); } else if (value !== undefined && value !== false) { el.setAttribute(key, String(value)); } } if (content) { el.textContent = content; } document.head.appendChild(el); } /** * Find an existing inline `<script>` tag (no `src`) with matching textContent. * Used to make `renderScriptTag` idempotent across hydration + navigation, * so SSR-emitted global scripts aren't re-appended client-side. */ protected findInlineScriptByContent( document: Document, content: string, ): Element | null { for (const existing of document.head.querySelectorAll( "script:not([src])", )) { if (existing.textContent === content) return existing; } return null; } protected renderMetaTag(document: Document, meta: HeadMeta): void { const { content } = meta; // Handle OpenGraph tags (property attribute) if (meta.property) { const existing = document.querySelector( `meta[property="${meta.property}"]`, ); if (existing) { existing.setAttribute("content", content); } else { const newMeta = document.createElement("meta"); newMeta.setAttribute("property", meta.property); newMeta.setAttribute("content", content); document.head.appendChild(newMeta); } return; } // Handle standard meta tags (name attribute) if (meta.name) { const existing = document.querySelector(`meta[name="${meta.name}"]`); if (existing) { existing.setAttribute("content", content); } else { const newMeta = document.createElement("meta"); newMeta.setAttribute("name", meta.name); newMeta.setAttribute("content", content); document.head.appendChild(newMeta); } } } }