alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
483 lines (482 loc) • 15.1 kB
JavaScript
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