UNPKG

preact-island

Version:

🏝 Create your own slice of paradise on any website.

334 lines (288 loc) 8.59 kB
import { ComponentType, h, render } from 'preact' import { InitialProps, Island } from './island' type HostElement = HTMLElement | ShadowRoot export const isInShadow = (node: HostElement | HTMLOrSVGScriptElement) => { return node.getRootNode() instanceof ShadowRoot } export const isShadowRoot = (x: unknown): x is ShadowRoot => { return x instanceof ShadowRoot } export const formatProp = (str: string) => { return `${str.charAt(0).toLowerCase()}${str.slice(1)}` } export const getPropsFromElement = ( element: HostElement | HTMLOrSVGScriptElement, ) => { // In a shadow dom we replace the host element because it's within the shadow root. However, // we want the props of the autonomous custom element. const targetElement = isInShadow(element) ? (element.getRootNode() as any).host : element const { dataset } = targetElement const props: { [x: string]: any } = {} for (var d in dataset) { // We don't pull props for inherited attributes if (dataset.hasOwnProperty(d) === false) return // data-prop or data-props works! const propName = formatProp(d.split(/(props?)/).pop() || '') if (propName) { props[propName] = dataset[d] } } return props } export const isValidPropsScript = (element: Element) => { return ( // element.tagName.toLowerCase() === 'script' && ['text/props', 'application/json'].includes( element.getAttribute('type') || '', ) ) } export const getInteriorPropsScriptsForElement = (element: HostElement) => { // getElementsByTagName does not exist on shadow roots and within a shadow root // the caller can't place in props scripts if (isShadowRoot(element)) return [] return Array.from(element.getElementsByTagName('script')).filter( isValidPropsScript, ) } export const getPropsScriptsBySelector = (selector: string) => { return Array.from(document.querySelectorAll(selector)).filter( isValidPropsScript, // Checked by filter call ) as HTMLOrSVGScriptElement[] } export const getPropsFromScripts = (scripts: HTMLOrSVGScriptElement[]) => { let interiorScriptProps: any = {} scripts.forEach((script) => { // Swallow any potential errors so we don't throw on someone else's page try { interiorScriptProps = { ...interiorScriptProps, ...JSON.parse(script.innerHTML), } } catch (e: any) {} }) return interiorScriptProps } /** * Get the props from a host element's data attributes * @param {Element} The host element * @return {Object} props object to be passed to the component */ export const generateHostElementProps = <P extends InitialProps>( island: Island<P>, element: HostElement, initialProps = {}, propsSelector: string | undefined | null, ): P => { const elementProps = getPropsFromElement(element) const currentScriptProps = island._executedScript ? getPropsFromElement(island._executedScript) : {} const interiorScriptProps = getPropsFromScripts( getInteriorPropsScriptsForElement(element), ) const propsSelectorProps = propsSelector ? getPropsFromScripts(getPropsScriptsBySelector(propsSelector)) : {} return { ...initialProps, ...elementProps, ...currentScriptProps, ...propsSelectorProps, ...interiorScriptProps, } } export const getHostElements = ({ selector, inline, elementName, }: { selector?: string inline: boolean /** * Passed if targeting web components so that mount in can create web components inside of the host elements */ elementName?: string }): HostElement[] => { const currentScript = document.currentScript if (inline && currentScript?.parentNode) { // @ts-ignore Not sure on this one return [currentScript.parentNode] } // Next, try to get the selector from the current script const maybeSelector = currentScript?.dataset.mountIn if (maybeSelector) { return Array.from( document.querySelectorAll<HTMLElement>(maybeSelector), ).map((n) => { if (elementName != null) { const targetElement = document.createElement(elementName) const node = n.appendChild(targetElement) return node.shadowRoot != null ? node.shadowRoot : node } return n }) } if (selector) { return Array.from(document.querySelectorAll<HTMLElement>(selector)).map( (n) => (n.shadowRoot != null ? n.shadowRoot : n), ) } return [] } /** * A Preact 11+ implementation of the `replaceNode` parameter from Preact 10. * * This creates a "Persistent Fragment" (a fake DOM element) containing one or more * DOM nodes, which can then be passed as the `parent` argument to Preact's `render()` method. * * Lifted from: https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c */ export type RootFragment = any export function createRootFragment( parent: HostElement, replaceNode: HostElement | HostElement[], ): RootFragment { replaceNode = ([] as HostElement[]).concat(replaceNode) var s = replaceNode[replaceNode.length - 1].nextSibling function insert(c: HTMLElement, r: HTMLElement) { parent.insertBefore(c, r || s) } // Mutating the parent to add a preact property // @ts-expect-error We're mutating the parent to add these properties for Preact return (parent.__k = { nodeType: 1, parentNode: parent, firstChild: replaceNode[0], childNodes: replaceNode, insertBefore: insert, appendChild: insert, removeChild: function (c: HTMLElement) { parent.removeChild(c) }, }) } export const watchForPropChanges = <P extends InitialProps>({ island, hostElement, initialProps, onNewProps, propsSelector, }: { island: Island<P> hostElement: HostElement initialProps: any onNewProps: (props: P) => void propsSelector: string | undefined | null }) => { const observer = new MutationObserver(function (mutations) { mutations.forEach(function () { onNewProps( generateHostElementProps( island, hostElement, initialProps, propsSelector, ), ) }) }) const config = { attributes: true, childList: true, characterData: true } if (island._executedScript) { observer.observe(island._executedScript, config) } getInteriorPropsScriptsForElement(hostElement).forEach((script) => { observer.observe(script, { ...config, subtree: true }) }) if (propsSelector) { getPropsScriptsBySelector(propsSelector).forEach((script) => { observer.observe(script, { ...config, subtree: true }) }) } /** * If the host element is a shadow root we want to observe on the host of it. * * Example: * <preact-element data-prop-foo="bar"> * #shadow-root (open) * </preact-element> * * We want to observe the custom autonomous element, not the shadow root! */ observer.observe( isShadowRoot(hostElement) ? hostElement.host! : hostElement, config, ) return observer } export const renderIsland = <P extends InitialProps>({ island, widget, rootFragment, props, }: { island: Island<P> widget: ComponentType<P> rootFragment: RootFragment props: P }) => { island.props = props render(h(widget, props), rootFragment) } export const mount = <P extends InitialProps>({ island, widget, hostElements, clean, replace, initialProps, propsSelector, }: { island: Island<P> widget: ComponentType<P> hostElements: Array<HostElement> clean: boolean replace: boolean initialProps: P propsSelector?: string }) => { const rootFragments: any = [] hostElements.forEach((hostElement) => { const props = generateHostElementProps<P>( island, hostElement, initialProps, propsSelector, ) if (clean) { hostElement.replaceChildren() } let rootFragment: any if (replace) { rootFragment = createRootFragment( hostElement.parentElement || document.body, hostElement, ) } else { const renderNode = document.createElement('div') hostElement.appendChild(renderNode) rootFragment = createRootFragment(hostElement, renderNode) } rootFragments.push(rootFragment) renderIsland({ island, widget, rootFragment, props }) const observer = watchForPropChanges<P>({ island, hostElement, initialProps, onNewProps: (newProps) => { renderIsland({ island, widget, rootFragment, props: newProps }) }, propsSelector, }) island._rootsToObservers.set(rootFragment, observer) }) return { rootFragments } }