import { FF } from './feature-flags.js'; import { createContext, createReadOnlyContext, guessValue, importCssModule, importModuleFromSource, getCurrentNode, eventEmitter, } from './internals.js'; import { linkTreeToContext, linkTreeToContextAsync } from './rules.js'; import { isRef, isReadOnlyRef, ref } from './reactivity.js'; import type { DefineComponentOptions, MountOptions } from './types'; const DEBUG = Symbol('#'); export function getInternals(t: any) { return t[DEBUG]; } function getOrigin(template: HTMLTemplateElement) { let url = template.getAttribute('origin'); if (!url) { url = window.location.href; if (template.getAttribute('component')) { // add origin to template to correctly load any relative imports within the template's source const origin = new URL(url); origin.pathname += '/' + template.getAttribute('component') + '.html'; template.setAttribute('origin', url); } } return url; } function getShadowDomOptions(template: HTMLTemplateElement): ShadowRootInit | undefined { const source = template.getAttribute('shadow-dom') || ''; if (source) { return source.startsWith('{') ? JSON.parse(source) : { mode: source as ShadowRootMode }; } } const loadCache = new Map(); export async function load(href: string | URL, baseUrl?: string | URL) { const origin = String(baseUrl || window.location.href); const fullUrl = String(new URL(href, origin)); if (loadCache.has(fullUrl)) { return loadCache.get(fullUrl); } try { const response = await fetch(fullUrl); if (!response.ok) { throw new Error('Failed to load components from ' + href); } const html = await response.text(); const dom = new DOMParser().parseFromString(html, 'text/html'); const templates = Array.from(dom.querySelectorAll('template[component]')) as HTMLTemplateElement[]; templates.forEach((t) => t.setAttribute('origin', fullUrl)); const definitions = templates.map((n) => defineFromTemplate(n)).filter(Boolean); const def = (await Promise.all(definitions)) as DefineComponentOptions[]; loadCache.set(fullUrl, def); return def; } catch (error) { console.error('Error loading component from', href, error); return []; } } export function loadCss(href: string | URL, options?: { adopt: boolean }) { const stylesheet = importCssModule(String(href)); if (options?.adopt !== false) { const { element } = getCurrentNode(); stylesheet.then((s) => (element.shadowRoot || document).adoptedStyleSheets.push(s)); } return stylesheet; } const invalidNames = [ 'annotation-xml', 'color-profile', 'font-face', 'font-face-src', 'font-face-uri', 'font-face-format', 'font-face-name', 'missing-glyph', ]; export function defineComponent(name: string, options: MountOptions) { if (invalidNames.includes(name)) { throw new Error( 'Invalid element name. See https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name', ); } const registered: any = customElements.get(name); if (registered) { if (!FF.debug) { console.error(`Component ${name} is already defined. Options only apply to new instances of ${name}`); } registered.options = options; return; } options.template = typeof options.template === 'string' ? tpl(options.template) : options.template; const shadowDom: ShadowRootInit | undefined = options.shadowDom === true ? { mode: 'open' } : getShadowDomOptions(options.template); options.shadowDom = shadowDom; class Component extends HTMLElement { static options = options; unmount: Function | null = null; constructor() { super(); if (shadowDom) { this.attachShadow(shadowDom); } } connectedCallback() { if (this.isConnected) { this.unmount = mount(this, Component.options); } else { this.unmount?.(); } } disconnectedCallback() { if (!this.isConnected) { this.unmount?.(); } } } customElements.define(name, Component); } export function mount(target: Element, options: MountOptions) { const parentElement = target.shadowRoot || target; const { template, setup = Function } = options; if (FF.linker && !(template as any).linker) { (template as any).linker = linkTreeToContextAsync(template.content); } const dom = document.createDocumentFragment(); dom.append(template.content.cloneNode(true)); const runtime = createContext(target, setup, dom); if (options.refs) { for (const [name, value, setter] of options.refs) { const $ = ref(guessValue(value)); runtime.context[name] = $; if (setter) { runtime.context[setter] = (v: any) => ($.value = v); } } } runtime.context.$$emit = eventEmitter.bind(null, target); const mergedContext = Object.assign({}, runtime.context, runtime.props, runtime.refs); const readOnlyContext = createReadOnlyContext(mergedContext); if (FF.linker) { (template as any).linker(dom, readOnlyContext); } else { linkTreeToContext(dom, readOnlyContext); } parentElement.innerHTML = ''; parentElement.appendChild(dom); if (options.styles?.length) { (target.shadowRoot || document).adoptedStyleSheets.push(...options.styles); } for (const fn of runtime.mount) { fn(); } if (FF.debug) { (parentElement as any)[DEBUG] = mergedContext; } const unmountHooks = runtime.unmount; return function () { for (const fn of unmountHooks) { fn(); } }; } async function findSetupModule(template: HTMLTemplateElement) { const stateCode = template.content.querySelector('script[state]'); const setupCode = template.content.querySelector('script[setup]'); const origin = getOrigin(template); let setupFunction; let stateFunction; if (stateCode) { const code = stateCode.textContent.trim(); stateFunction = () => JSON.parse(code); stateCode.remove(); } if (setupCode) { const src = setupCode.getAttribute('src'); const code = setupCode.textContent; const mod = src ? import(String(new URL(src, origin))) : importModuleFromSource(code, origin); setupFunction = (await mod).default; setupCode.remove(); } if (setupFunction && stateFunction) { return async function () { const state = stateFunction(); const bindings = setupFunction() || {}; for (const [key, value] of Object.entries(state)) { const $ = bindings[key]; if (isRef($)) { if (!isReadOnlyRef($)) { $.value = value; } } else { bindings[key] = value; } } return bindings; }; } return setupFunction || stateFunction || Function; } async function findStyleSheets(template: HTMLTemplateElement): Promise { const styleTags = Array.from(template.content.querySelectorAll('style')) as HTMLStyleElement[]; const linkTags = Array.from(template.content.querySelectorAll('link[rel="stylesheet"]')) as HTMLLinkElement[]; if (!(styleTags.length || linkTags.length)) { return []; } const styles = styleTags.map((tag) => { let sheet = tag.sheet; if (!sheet) { sheet = new CSSStyleSheet(); sheet.replaceSync(tag.textContent.trim()); } tag.remove(); return sheet; }); const origin = getOrigin(template); const links = linkTags.map((link) => { const href = String(new URL(link.href, origin)); const sheet = importCssModule(href); link.remove(); return sheet; }); return (await Promise.all(links)).concat(styles); } function findRefs(template: HTMLTemplateElement) { const list = Array.from(template.content.querySelectorAll('ref')); return list.map((r) => { r.remove(); const name = r.getAttribute('name'); const setterName = r.getAttribute('setter'); return [name, r.getAttribute('value'), setterName]; }); } export function loadDependencies(template: HTMLTemplateElement) { const links = Array.from(template.content.querySelectorAll('link[rel=component]')) as HTMLLinkElement[]; const origin = getOrigin(template); for (const link of links) { load(link.href, origin); link.remove(); } } export function tpl(s: string) { const template = document.createElement('template'); template.innerHTML = String(s).trim(); return template; } export async function readOptionsFromTemplate(template: HTMLTemplateElement) { loadDependencies(template); const options: MountOptions = { template, setup: await findSetupModule(template), styles: await findStyleSheets(template), refs: findRefs(template), }; return options; } export async function defineFromTemplate( template: HTMLTemplateElement | string, ): Promise { if (typeof template === 'string') { template = tpl(template); } const name = template.getAttribute('component') as string; if (!name) { return null; } const options = await readOptionsFromTemplate(template); defineComponent(name, options); return { name, ...options }; } export function findApps() { const apps = Array.from(document.querySelectorAll('template[app]')) as HTMLTemplateElement[]; for (const template of apps) { readOptionsFromTemplate(template) .then((options) => { const app = document.createElement('div'); app.style.display = 'contents'; template.parentNode!.insertBefore(app, template); mount(app, options); FF.debug || template.remove(); }) .catch((error) => console.error(error)); } } export function autoInitialize() { const components = Array.from(document.querySelectorAll('template[component]')) as HTMLTemplateElement[]; const links = Array.from(document.querySelectorAll('link[rel="component"]')) as HTMLLinkElement[]; components.forEach((c) => defineFromTemplate(c)); links.forEach((l) => load(l.href)); findApps(); } // give time to import the module and set feature flags setTimeout(() => { if (!FF.skipAutoInitialize) { if (['complete', 'interactive'].includes(document.readyState)) { return autoInitialize(); } window.addEventListener('DOMContentLoaded', autoInitialize); } }, 10);