/** * MIT License * * Copyright (c) 2025 Chris M. Perez * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { Effect, Context, Ref, Layer, Option as EffectOption, pipe, } from 'effect'; import type { HeadProps, MetaTag, LinkTag, ScriptTag } from './types.js'; export class HeadRegistry extends Context.Tag('HeadRegistry')< HeadRegistry, { readonly push: (head: HeadProps) => Effect.Effect; readonly getMerged: () => Effect.Effect; readonly clear: () => Effect.Effect; } >() {} export const mergeHeadProps = ( base: HeadProps, override: HeadProps ): HeadProps => { const merged: HeadProps = { ...base }; const mutable = merged as Record; const scalars: (keyof HeadProps)[] = [ 'title', 'description', 'canonical', 'viewport', 'charset', 'lang', 'themeColor', 'favicon', 'base', 'robots', ]; for (const key of scalars) { if (override[key] !== undefined) { mutable[key] = override[key]; } } if (override.og) { mutable.og = { ...base.og, ...override.og }; } if (override.twitter) { mutable.twitter = { ...base.twitter, ...override.twitter }; } if (override.meta) { const existing = base.meta ?? []; const newMeta = dedupeMetaTags([...existing, ...override.meta]); mutable.meta = newMeta; } if (override.link) { const existing = base.link ?? []; const newLinks = dedupeLinkTags([...existing, ...override.link]); mutable.link = newLinks; } if (override.script) { const existing = base.script ?? []; const newScripts = dedupeScriptTags([...existing, ...override.script]); mutable.script = newScripts; } return merged; }; const dedupeMetaTags = (tags: readonly MetaTag[]): MetaTag[] => { const seen = new Map(); for (const tag of tags) { const key = tag.name ?? tag.property ?? tag.content; seen.set(key, tag); } return Array.from(seen.values()); }; const dedupeLinkTags = (tags: readonly LinkTag[]): LinkTag[] => { const seen = new Map(); for (const tag of tags) { const key = `${tag.rel}:${tag.href}`; seen.set(key, tag); } return Array.from(seen.values()); }; const dedupeScriptTags = (tags: readonly ScriptTag[]): ScriptTag[] => { const seen = new Map(); for (const tag of tags) { const key = pipe( EffectOption.fromNullable(tag.src), EffectOption.orElse(() => EffectOption.fromNullable(tag.id)), EffectOption.orElse(() => pipe( EffectOption.fromNullable(tag.content), EffectOption.map((c) => c.slice(0, 50)) ) ), EffectOption.getOrElse(() => '') ); if (key) { seen.set(key, tag); } } return Array.from(seen.values()); }; export const HeadRegistryLive = Layer.effect( HeadRegistry, Effect.gen(function* () { const stackRef = yield* Ref.make([]); return { push: (head: HeadProps) => Ref.update(stackRef, (stack) => [...stack, head]), getMerged: () => Effect.gen(function* () { const stack = yield* Ref.get(stackRef); return stack.reduce( (acc, head) => mergeHeadProps(acc, head), {} ); }), clear: () => Ref.set(stackRef, []), }; }) ); export const mergeLayerHeads = (heads: readonly HeadProps[]): HeadProps => { return heads.reduce((acc, head) => mergeHeadProps(acc, head), {}); }; export const headToHtml = (head: HeadProps): string => { const parts: string[] = []; if (head.charset) { parts.push(``); } else { parts.push(''); } if (head.viewport) { parts.push(``); } else { parts.push( '' ); } if (head.title) { parts.push(`${escapeHtml(head.title)}`); } if (head.description) { parts.push( `` ); } if (head.canonical) { parts.push(``); } if (head.base) { parts.push(``); } if (head.themeColor) { parts.push( `` ); } if (head.favicon) { parts.push(``); } if (head.robots) { parts.push(``); } if (head.og) { for (const [key, value] of Object.entries(head.og)) { if (value) { parts.push( `` ); } } } if (head.twitter) { for (const [key, value] of Object.entries(head.twitter)) { if (value) { parts.push( `` ); } } } if (head.meta) { for (const tag of head.meta) { const attrs: string[] = []; if (tag.name) attrs.push(`name="${escapeAttr(tag.name)}"`); if (tag.property) attrs.push(`property="${escapeAttr(tag.property)}"`); if (tag.httpEquiv) attrs.push(`http-equiv="${escapeAttr(tag.httpEquiv)}"`); attrs.push(`content="${escapeAttr(tag.content)}"`); parts.push(``); } } if (head.link) { for (const tag of head.link) { const attrs = Object.entries(tag) .filter(([, v]) => v !== undefined) .map(([k, v]) => `${k}="${escapeAttr(String(v))}"`) .join(' '); parts.push(``); } } if (head.script) { for (const tag of head.script) { const attrs: string[] = []; if (tag.src) attrs.push(`src="${escapeAttr(tag.src)}"`); if (tag.type) attrs.push(`type="${escapeAttr(tag.type)}"`); if (tag.async) attrs.push('async'); if (tag.defer) attrs.push('defer'); if (tag.id) attrs.push(`id="${escapeAttr(tag.id)}"`); const attrStr = attrs.length > 0 ? ` ${attrs.join(' ')}` : ''; const content = tag.content ? escapeHtml(tag.content) : ''; parts.push(`${content}`); } } return parts.join('\n\t'); }; const escapeHtml = (str: string): string => { return str.replace(/&/g, '&').replace(//g, '>'); }; const escapeAttr = (str: string): string => { return str .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, '''); };