UNPKG

alepha

Version:

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

483 lines (482 loc) 15.1 kB
import { $inject, $module, Alepha, KIND, Primitive, createPrimitive } from "alepha"; import { AlephaReact, useInject } from "alepha/react"; import { $logger } from "alepha/logger"; import { useCallback, useEffect, useMemo } from "react"; //#region ../../src/react/head/helpers/SeoExpander.ts /** * Expands Head configuration into SEO meta tags. * * Generates: * - `<meta name="description">` from head.description * - `<meta property="og:*">` OpenGraph tags * - `<meta name="twitter:*">` Twitter Card tags * * @example * ```ts * const helper = new SeoExpander(); * const { meta, link } = helper.expand({ * title: "My App", * description: "Build amazing apps", * image: "https://example.com/og.png", * url: "https://example.com/", * }); * ``` */ var SeoExpander = class { expand(head) { const meta = []; const link = []; if (!(head.description || head.image || head.url || head.siteName || head.locale || head.type || head.og || head.twitter)) return { meta, link }; if (head.description) meta.push({ name: "description", content: head.description }); if (head.url) link.push({ rel: "canonical", href: head.url }); this.expandOpenGraph(head, meta); this.expandTwitter(head, meta); return { meta, link }; } expandOpenGraph(head, meta) { const ogTitle = head.og?.title ?? head.title; const ogDescription = head.og?.description ?? head.description; const ogImage = head.og?.image ?? head.image; if (head.type || ogTitle) meta.push({ property: "og:type", content: head.type ?? "website" }); if (head.url) meta.push({ property: "og:url", content: head.url }); if (ogTitle) meta.push({ property: "og:title", content: ogTitle }); if (ogDescription) meta.push({ property: "og:description", content: ogDescription }); if (ogImage) { meta.push({ property: "og:image", content: ogImage }); if (head.imageWidth) meta.push({ property: "og:image:width", content: String(head.imageWidth) }); if (head.imageHeight) meta.push({ property: "og:image:height", content: String(head.imageHeight) }); if (head.imageAlt) meta.push({ property: "og:image:alt", content: head.imageAlt }); } if (head.siteName) meta.push({ property: "og:site_name", content: head.siteName }); if (head.locale) meta.push({ property: "og:locale", content: head.locale }); } expandTwitter(head, meta) { const twitterTitle = head.twitter?.title ?? head.title; const twitterDescription = head.twitter?.description ?? head.description; const twitterImage = head.twitter?.image ?? head.image; if (head.twitter?.card || twitterTitle || twitterImage) meta.push({ name: "twitter:card", content: head.twitter?.card ?? (twitterImage ? "summary_large_image" : "summary") }); if (head.url) meta.push({ name: "twitter:url", content: head.url }); if (twitterTitle) meta.push({ name: "twitter:title", content: twitterTitle }); if (twitterDescription) meta.push({ name: "twitter:description", content: twitterDescription }); if (twitterImage) { meta.push({ name: "twitter:image", content: twitterImage }); if (head.imageAlt) meta.push({ name: "twitter:image:alt", content: head.imageAlt }); } if (head.twitter?.site) meta.push({ name: "twitter:site", content: head.twitter.site }); if (head.twitter?.creator) meta.push({ name: "twitter:creator", content: head.twitter.creator }); } }; //#endregion //#region ../../src/react/head/providers/HeadProvider.ts /** * Provides methods to fill and merge head information into the application state. * * Used both on server and client side to manage document head. * * @see {@link SeoExpander} * @see {@link ServerHeadProvider} * @see {@link BrowserHeadProvider} */ var HeadProvider = class { log = $logger(); seoExpander = $inject(SeoExpander); global = []; /** * Track if we've warned about page-level htmlAttributes to avoid spam. */ warnedAboutHtmlAttributes = false; /** * Resolve global head configuration (from $head primitives only). * * This is used to get htmlAttributes early, before page loaders run. * Only htmlAttributes from global $head are allowed; page-level htmlAttributes * are ignored for early streaming optimization. * * @returns Merged global head with htmlAttributes */ resolveGlobalHead() { const head = { htmlAttributes: { lang: "en" } }; for (const h of this.global ?? []) { const resolved = typeof h === "function" ? h() : h; if (resolved.htmlAttributes) head.htmlAttributes = { ...head.htmlAttributes, ...resolved.htmlAttributes }; } return head; } /** * Fully resolve all global $head entries (functions re-evaluated, objects kept as-is). * * Unlike resolveGlobalHead() which only extracts htmlAttributes for streaming, * this resolves all head properties (meta, link, script, htmlAttributes, etc.). * * Used by BrowserHeadProvider.refreshGlobalHead() to re-apply global head to the DOM. */ resolveGlobal() { let head = {}; for (const h of this.global ?? []) { const resolved = typeof h === "function" ? h() : h; const { meta, link } = this.seoExpander.expand(resolved); head = { ...head, ...resolved, meta: [ ...head.meta ?? [], ...meta, ...resolved.meta ?? [] ], link: [ ...head.link ?? [], ...link, ...resolved.link ?? [] ], script: [...head.script ?? [], ...resolved.script ?? []] }; } return head; } fillHead(state) { state.head = { ...state.head }; for (const h of this.global ?? []) { const head = typeof h === "function" ? h() : h; this.mergeHead(state, head); } for (const layer of state.layers) if (layer.route?.head && !layer.error) this.fillHeadByPage(layer.route, state, layer.props ?? {}); state.head.title ??= "App"; state.head.htmlAttributes = { lang: "en", ...state.head.htmlAttributes }; } mergeHead(state, head) { const { meta, link } = this.seoExpander.expand(head); state.head = { ...state.head, ...head, meta: [ ...state.head.meta ?? [], ...meta, ...head.meta ?? [] ], link: [ ...state.head.link ?? [], ...link, ...head.link ?? [] ], script: [...state.head.script ?? [], ...head.script ?? []] }; } fillHeadByPage(page, state, props) { if (!page.head) return; state.head ??= {}; const head = typeof page.head === "function" ? page.head(props, state.head) : page.head; const { meta, link } = this.seoExpander.expand(head); state.head.meta = [...state.head.meta ?? [], ...meta]; state.head.link = [...state.head.link ?? [], ...link]; if (head.title) { state.head ??= {}; if (state.head.titleSeparator) state.head.title = `${head.title}${state.head.titleSeparator}${state.head.title}`; else state.head.title = head.title; state.head.titleSeparator = head.titleSeparator; } if (head.htmlAttributes && !this.warnedAboutHtmlAttributes) { this.warnedAboutHtmlAttributes = true; this.log.warn("Page-level htmlAttributes are ignored. Use global $head() for htmlAttributes instead, as they are sent before page loaders run for early streaming optimization."); } if (head.bodyAttributes) state.head.bodyAttributes = { ...state.head.bodyAttributes, ...head.bodyAttributes }; if (head.meta) state.head.meta = [...state.head.meta ?? [], ...head.meta ?? []]; if (head.link) state.head.link = [...state.head.link ?? [], ...head.link ?? []]; if (head.script) state.head.script = [...state.head.script ?? [], ...head.script ?? []]; } }; //#endregion //#region ../../src/react/head/primitives/$head.ts /** * Set global `<head>` options for the application. */ const $head = (options) => { return createPrimitive(HeadPrimitive, options); }; var HeadPrimitive = class extends Primitive { provider = $inject(HeadProvider); onInit() { this.provider.global = [...this.provider.global ?? [], this.options]; } }; $head[KIND] = HeadPrimitive; //#endregion //#region ../../src/react/head/providers/BrowserHeadProvider.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. */ var BrowserHeadProvider = class { alepha = $inject(Alepha); headProvider = $inject(HeadProvider); get 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. */ fillAndRenderHead(state) { if (!this.alepha.isBrowser()) return; this.headProvider.fillHead(state); 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. */ refreshGlobalHead() { const head = this.headProvider.resolveGlobal(); this.renderHead(this.document, head); } getHead(document) { return { get title() { return document.title; }, get htmlAttributes() { const attrs = {}; for (const attr of document.documentElement.attributes) attrs[attr.name] = attr.value; return attrs; }, get bodyAttributes() { const attrs = {}; for (const attr of document.body.attributes) attrs[attr.name] = attr.value; return attrs; }, get meta() { const metas = []; 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 }); } 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; } }; } renderHead(document, head) { 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); } renderScriptTag(document, script) { 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; if (attrs.src) { if (document.querySelector(`script[src="${attrs.src}"]`)) return; } else if (typeof attrs.id === "string") { if (document.querySelector(`script#${CSS.escape(attrs.id)}`)) return; } else if (content) { 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 !== void 0 && 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. */ findInlineScriptByContent(document, content) { for (const existing of document.head.querySelectorAll("script:not([src])")) if (existing.textContent === content) return existing; return null; } renderMetaTag(document, meta) { const { content } = meta; 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; } 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); } } } }; //#endregion //#region ../../src/react/head/hooks/useHead.ts /** * ```tsx * const App = () => { * const [head, setHead] = useHead({ * // will set the document title on the first render * title: "My App", * }); * * return ( * // This will update the document title when the button is clicked * <button onClick={() => setHead({ title: "Change Title" })}> * Change Title {head.title} * </button> * ); * } * ``` */ const useHead = (options) => { const alepha = useInject(Alepha); const current = useMemo(() => { if (!alepha.isBrowser()) return {}; return alepha.inject(BrowserHeadProvider).getHead(window.document); }, []); const setHead = useCallback((head) => { if (!alepha.isBrowser()) return; const headProvider = alepha.inject(BrowserHeadProvider); const resolved = typeof head === "function" ? head(headProvider.getHead(window.document)) : head || {}; headProvider.renderHead(window.document, resolved); }, []); useEffect(() => { if (options) setHead(options); }, []); return [current, setHead]; }; //#endregion //#region ../../src/react/head/index.browser.ts /** * Alepha React Head Module * * @see {@link BrowserHeadProvider} * @module alepha.react.head */ const AlephaReactHead = $module({ name: "alepha.react.head", primitives: [$head], services: [AlephaReact, BrowserHeadProvider] }); //#endregion export { $head, AlephaReactHead, BrowserHeadProvider, HeadPrimitive, SeoExpander, useHead }; //# sourceMappingURL=index.browser.js.map