import type { Component } from '@toddledev/core/dist/component/component.types' import { HeadTagTypes } from '@toddledev/core/dist/component/component.types' import type { ToddleComponent } from '@toddledev/core/dist/component/ToddleComponent' import type { FormulaContext } from '@toddledev/core/dist/formula/formula' import { applyFormula } from '@toddledev/core/dist/formula/formula' import type { OldTheme, Theme } from '@toddledev/core/dist/styling/theme' import { easySort } from '@toddledev/core/dist/utils/collections' import { validateUrl } from '@toddledev/core/dist/utils/url' import { isDefined } from '@toddledev/core/dist/utils/util' import { VOID_HTML_ELEMENTS } from '../const' import { escapeAttrValue } from '../rendering/attributes' import type { ProjectFiles, ToddleProject } from '../ssr.types' import { isCloudflareImagePath } from '../utils/media' import { nanoid } from '../utils/nanoid' import { getFontCssUrl } from './fonts' import { getCharset } from './html' import { defaultSpeculationRules } from './speculation' type Text = string export type HeadItemType = `${HeadTagTypes}:${Text}` | 'title' /** * Returns all head items for a given page */ export const getHeadItems = ({ cacheBuster, context, cssBasePath = '/.toddle/fonts/stylesheet/css2', page, resetStylesheetPath = '/_static/reset.css', pageStylesheetPath = `/.toddle/stylesheet/${page.name}.css`, files, project, theme, url, }: { // Optional cache buster for reset stylesheet + manifest url. Could be a commit sha for instance cacheBuster?: string context: FormulaContext cssBasePath?: string files: ProjectFiles page: ToddleComponent resetStylesheetPath?: string pageStylesheetPath?: string project: ToddleProject theme: OldTheme | Theme url: URL }): Map => { const pageInfo = page.route?.info const title = getPageTitle({ component: page, context, defaultTitle: project.name, }) const description = getPageDescription({ component: page, context, defaultDescription: project.description, }) const preloadFonts: [HeadItemType, string][] = [] if ('breakpoints' in theme === false) { // We include all fonts even though it's not necessary. // While this is not the ideal long-term solution, it does have a few benefits: // - It's easier to cache the font stylesheet across pages // - It simplifies style variable setup in theme.ts // - It ensures the same behaviour as in our editor // - It increases the chance that there's a font to be used by our // reset stylesheet (font-family: var(--font-sans)) // For most apps, the overhead of including all fonts is negligible and // it doesn't add a lot of bytes to our stylesheet. // Add link to stylesheet that includes the different font-faces if (theme.fonts.length > 0) { const fontStylesheetUrl = getFontCssUrl({ fonts: theme.fonts, basePath: cssBasePath, }) if (fontStylesheetUrl) { preloadFonts.push([ // Later we'll support multiple font loading strategies aside from swap 'link:font:swap', // See https://fonts.google.com/selection/embed ``, ]) } } } const charset = getCharset({ pageInfo, formulaContext: context }) const descriptionItems: [HeadItemType, string][] = [] if (typeof description === 'string') { // Only add meta:description and og:description if a description exists descriptionItems.push([ 'meta:description', ``, ]) descriptionItems.push([ 'meta:og:description', ``, ]) } const headItems = new Map([ [ 'link:reset', ``, ], [ 'link:page', ``, ], ...preloadFonts, // Initialize default head items (meta + links) // these might be overwritten by custom tags later ['meta:charset', ``], [ 'meta:viewport', ``, ], // Title + og:title + apple-mobile-web-app-title ['title', `${title}`], [ 'meta:og:title', ``, ], [ 'meta:apple-mobile-web-app-title', ``, ], // Description + og:description ...descriptionItems, ['meta:og:type', ``], [ 'meta:og:url', ``, ], [ 'meta:application-name', ``, ], [ 'script:speculationrules', ``, ], ]) if (project.type === 'package' && project.thumbnail) { // Packages usually have a thumbnail set. In case the user picked a custom og:image, // the thumbnail will be overwritten by the user's og:image const thumbnailUrl = isCloudflareImagePath(project.thumbnail.path) ? `${url.origin}${project.thumbnail.path}/256` : project.thumbnail.path headItems.set( 'meta:og:image', ``, ) } const manifestUrl = validateUrl( applyFormula(files.config?.meta?.manifest?.formula, context), ) if (manifestUrl) { const manifestUrl = urlWithCacheBuster('/manifest.json', cacheBuster) headItems.set( 'link:manifest', ``, ) } else { // Only add a default theme-color + msapplication-TileColor if there is no manifest declared headItems.set( 'meta:theme-color', '', ) headItems.set( 'meta:msapplication-tilecolor', '', ) } const hasCustomMeta = (nameOrProperty: string) => Object.values(pageInfo?.meta ?? {}).some((m) => Object.entries(m.attrs ?? {}).some( ([k, value]) => ['name', 'property'].includes(k.toLowerCase()) && value.type === 'value' && typeof value.value === 'string' && value.value.toLowerCase() === nameOrProperty.toLowerCase(), ), ) const icon = files.config?.meta?.icon if (isDefined(icon)) { const iconPath = applyFormula(icon.formula, context) if (isDefined(iconPath) && typeof iconPath === 'string') { if (isCloudflareImagePath(iconPath)) { // If the icon is a cloudflare image path, we add the different sizes const basePath = iconPath.split('/').slice(0, -1).join('/') headItems.set( 'link:icon:16', ``, ) headItems.set( 'link:icon:32', ``, ) headItems.set( 'link:icon', ``, ) } else { headItems.set( 'link:icon', ``, ) } } } else if ( // Only add default icons if no icon is set ![ 'link:icon', 'link:mask-icon', 'link:apple-touch-icon', 'link:icon:16', 'link:icon:32', ].every((k) => !hasCustomMeta(k)) ) { if (project.emoji) { // Use emoji as icon headItems.set( 'link:icon', ``, ) } headItems.set( 'link:mask-icon', '', ) headItems.set( 'link:apple-touch-icon', '', ) headItems.set( 'link:icon:16', '', ) headItems.set( 'link:icon:32', '', ) } // Handle custom meta tags last to allow overriding defaults if (pageInfo?.meta) { Object.entries(pageInfo.meta).forEach(([id, metaEntry]) => { if (Object.values(HeadTagTypes).includes(metaEntry.tag)) { // If the tag has a name or property attribute, we use that as the key // to avoid duplicates and to ensure sorting of tags later const key = Object.entries(metaEntry.attrs ?? {}).find( ([key]) => key === 'name' || key === 'property', ) const headItemKey: HeadItemType = `${metaEntry.tag}:${ isDefined(key) ? applyFormula(key[1], context) : (id ?? nanoid()) }` headItems.set( headItemKey, // Add the id to the tag so it's easier to dynamically update it later // from our runtime (main.ts) `<${metaEntry.tag} data-toddle-id="${id}" ${Object.entries( metaEntry.attrs ?? {}, ) .map(([key, formula]) => { const value = applyFormula(formula, context) if (value === true) { // If the value is true, we just return the key - this is useful // for tags like