|
1 | 1 | import { Window } from 'happy-dom' |
| 2 | +import type HappyDomNode from 'happy-dom/lib/nodes/node/Node' |
| 3 | +import type HappyDomDocumentFragment from 'happy-dom/lib/nodes/document-fragment/DocumentFragment' |
2 | 4 | import type { experimental_AstroContainer as AstroContainer } from 'astro/container' |
3 | 5 | import { BuildError } from '@lib/errors/BuildError' |
| 6 | +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' |
4 | 7 |
|
5 | 8 | type HappyDomGlobalKey = 'window' | 'document' | 'customElements' | 'HTMLElement' |
6 | 9 | type HappyDomGlobals = Partial<Record<HappyDomGlobalKey, unknown>> |
@@ -70,44 +73,168 @@ export const withHappyDomEnvironment = async <TReturn>( |
70 | 73 | } |
71 | 74 | } |
72 | 75 |
|
73 | | -interface RenderInHappyDomOptions<TElement, THydrateResult> { |
| 76 | +export type ModuleElement<TModule extends WebComponentModule> = TModule extends WebComponentModule<infer TElement> |
| 77 | + ? TElement |
| 78 | + : HTMLElement |
| 79 | + |
| 80 | +export interface RenderInHappyDomOptions<TModule extends WebComponentModule> { |
74 | 81 | container: AstroContainer |
75 | 82 | component: AstroComponentInstance |
76 | 83 | args?: AstroRenderArgs |
77 | | - selector: string |
78 | | - hydrate?: () => Promise<THydrateResult> | THydrateResult |
79 | | - waitForReady?: (_element: TElement) => Promise<void> | void |
| 84 | + moduleLoader: () => Promise<TModule> | TModule |
| 85 | + selector?: string |
| 86 | + waitForReady?: (_element: ModuleElement<TModule>) => Promise<void> | void |
80 | 87 | assert: (_context: { |
81 | | - element: TElement |
| 88 | + element: ModuleElement<TModule> |
82 | 89 | window: Window |
83 | | - hydrateResult: THydrateResult |
| 90 | + module: TModule |
84 | 91 | renderResult: RenderResult |
85 | 92 | }) => Promise<void> | void |
86 | 93 | } |
87 | 94 |
|
88 | | -const defaultWaitForReady = async () => {} |
| 95 | +const defaultWaitForReady = async (_element?: unknown) => {} |
| 96 | + |
| 97 | +const componentModuleImporters = import.meta.glob<ModuleImport<WebComponentModule>>( |
| 98 | + '/src/components/**/*.ts', |
| 99 | +) |
| 100 | + |
| 101 | +const sanitizeRenderedHtml = (html: string): string => { |
| 102 | + const withoutScripts = html.replace(/<script[\s\S]*?<\/script>/gi, '') |
| 103 | + return withoutScripts.replace(/\sdata-astro-cid-[^=\s>]+="[^"]*"/gi, '') |
| 104 | +} |
| 105 | + |
| 106 | +const hasFileExtension = (specifier: string): boolean => /\.[a-zA-Z0-9]+$/.test(specifier) |
| 107 | + |
| 108 | +const normalizeModuleSpecifier = (moduleSpecifier: string): string => { |
| 109 | + const ensureExtension = (specifier: string) => (hasFileExtension(specifier) ? specifier : `${specifier}.ts`) |
| 110 | + |
| 111 | + if (moduleSpecifier.startsWith('@components/')) { |
| 112 | + const relativePath = moduleSpecifier.slice('@components/'.length) |
| 113 | + return `/src/components/${ensureExtension(relativePath)}` |
| 114 | + } |
| 115 | + |
| 116 | + return ensureExtension(moduleSpecifier) |
| 117 | +} |
| 118 | + |
| 119 | +export type HappyDomRenderer<TModule extends WebComponentModule> = ( |
| 120 | + _options: RenderInHappyDomOptions<TModule>, |
| 121 | +) => Promise<void> |
89 | 122 |
|
90 | | -export const renderInHappyDom = async <TElement, THydrateResult = void>( |
91 | | - options: RenderInHappyDomOptions<TElement, THydrateResult>, |
| 123 | +export const renderInHappyDom = async <TModule extends WebComponentModule>( |
| 124 | + _options: RenderInHappyDomOptions<TModule>, |
92 | 125 | ): Promise<void> => { |
93 | | - const { container, component, args, selector, hydrate, waitForReady = defaultWaitForReady, assert } = options |
| 126 | + const { container, component, args, moduleLoader, selector, waitForReady = defaultWaitForReady, assert } = _options |
94 | 127 |
|
95 | 128 | await withHappyDomEnvironment(async ({ window }) => { |
96 | | - const renderedHtml: RenderResult = await container.renderToString(component, args) |
97 | | - |
98 | | - window.document.body.innerHTML = renderedHtml |
99 | | - window.document.querySelectorAll('script').forEach((script) => script.remove()) |
| 129 | + const module = await moduleLoader() |
100 | 130 |
|
101 | | - const hydrateResult = (await hydrate?.()) as THydrateResult |
| 131 | + const renderedHtml: RenderResult = await container.renderToString(component, args) |
102 | 132 |
|
103 | | - const element = window.document.querySelector(selector) as TElement | null |
| 133 | + const sanitizedHtml = sanitizeRenderedHtml(renderedHtml) |
| 134 | + const template = window.document.createElement('template') |
| 135 | + template.innerHTML = sanitizedHtml |
| 136 | + await module.registerWebComponent(module.registeredName) |
| 137 | + const fragment = template.content.cloneNode(true) as HappyDomDocumentFragment |
| 138 | + const placeholders = Array.from(fragment.querySelectorAll(module.registeredName)) |
| 139 | + placeholders.forEach((placeholder) => { |
| 140 | + const upgradedElement = window.document.createElement(module.registeredName) |
| 141 | + Array.from(placeholder.attributes).forEach((attribute) => { |
| 142 | + upgradedElement.setAttribute(attribute.name, attribute.value ?? '') |
| 143 | + }) |
| 144 | + while (placeholder.firstChild) { |
| 145 | + upgradedElement.appendChild(placeholder.firstChild as HappyDomNode) |
| 146 | + } |
| 147 | + placeholder.replaceWith(upgradedElement as HappyDomNode) |
| 148 | + }) |
| 149 | + window.document.body.innerHTML = '' |
| 150 | + window.document.body.appendChild(fragment as HappyDomNode) |
| 151 | + |
| 152 | + const querySelector = selector ?? module.registeredName |
| 153 | + const element = window.document.querySelector(querySelector) as ModuleElement<TModule> | null |
104 | 154 | if (!element) { |
105 | 155 | throw BuildError.fileOperation( |
106 | | - `Unable to locate rendered element for selector "${selector}" during Happy DOM rendering`, |
| 156 | + `Unable to locate rendered element for selector "${querySelector}" during Happy DOM rendering`, |
| 157 | + ) |
| 158 | + } |
| 159 | + |
| 160 | + const registeredCtor = window.customElements.get(module.registeredName) as |
| 161 | + | CustomElementConstructor |
| 162 | + | undefined |
| 163 | + const expectedCtor: CustomElementConstructor = module.componentCtor |
| 164 | + if (!registeredCtor || registeredCtor !== expectedCtor) { |
| 165 | + throw BuildError.fileOperation( |
| 166 | + `Custom element "${module.registeredName}" is not registered with the expected constructor`, |
107 | 167 | ) |
108 | 168 | } |
109 | 169 |
|
110 | 170 | await waitForReady(element) |
111 | | - await assert({ element, window, hydrateResult, renderResult: renderedHtml }) |
| 171 | + await assert({ element, window, module, renderResult: renderedHtml }) |
112 | 172 | }) |
113 | 173 | } |
| 174 | + |
| 175 | +interface ModuleImport<TModule extends WebComponentModule> { |
| 176 | + webComponentModule?: TModule |
| 177 | +} |
| 178 | + |
| 179 | +export const loadWebComponentModule = async <TModule extends WebComponentModule>( |
| 180 | + moduleSpecifier: string, |
| 181 | +): Promise<TModule> => { |
| 182 | + const normalizedSpecifier = normalizeModuleSpecifier(moduleSpecifier) |
| 183 | + const importer = componentModuleImporters[normalizedSpecifier] |
| 184 | + |
| 185 | + if (!importer) { |
| 186 | + throw BuildError.fileOperation( |
| 187 | + `Unable to locate a component module for specifier "${moduleSpecifier}" (resolved to "${normalizedSpecifier}")`, |
| 188 | + ) |
| 189 | + } |
| 190 | + |
| 191 | + const imported = (await importer()) as ModuleImport<TModule> |
| 192 | + if (!imported.webComponentModule) { |
| 193 | + throw BuildError.fileOperation( |
| 194 | + `Module "${moduleSpecifier}" does not export a webComponentModule contract`, |
| 195 | + ) |
| 196 | + } |
| 197 | + |
| 198 | + return imported.webComponentModule |
| 199 | +} |
| 200 | + |
| 201 | +export interface ExecuteRenderOptions<TModule extends WebComponentModule> { |
| 202 | + container: AstroContainer |
| 203 | + component: AstroComponentInstance |
| 204 | + moduleSpecifier: string |
| 205 | + args?: AstroRenderArgs |
| 206 | + selector?: string |
| 207 | + waitForReady?: (_element: ModuleElement<TModule>) => Promise<void> | void |
| 208 | + renderer?: HappyDomRenderer<TModule> |
| 209 | + assert: (_context: { element: ModuleElement<TModule>; module: TModule; renderResult: RenderResult }) => Promise<void> | void |
| 210 | +} |
| 211 | + |
| 212 | +export const executeRender = async <TModule extends WebComponentModule>( |
| 213 | + options: ExecuteRenderOptions<TModule>, |
| 214 | +): Promise<void> => { |
| 215 | + const { container, component, moduleSpecifier, args, selector, waitForReady, renderer, assert } = options |
| 216 | + |
| 217 | + const rendererToUse: HappyDomRenderer<TModule> = renderer |
| 218 | + ? renderer |
| 219 | + : (rendererOptions) => renderInHappyDom<TModule>(rendererOptions) |
| 220 | + |
| 221 | + const rendererOptions: RenderInHappyDomOptions<TModule> = { |
| 222 | + container, |
| 223 | + component, |
| 224 | + args, |
| 225 | + moduleLoader: () => loadWebComponentModule<TModule>(moduleSpecifier), |
| 226 | + assert: async ({ element, module, renderResult }) => { |
| 227 | + await assert({ element, module, renderResult }) |
| 228 | + }, |
| 229 | + } |
| 230 | + |
| 231 | + if (typeof selector === 'string') { |
| 232 | + rendererOptions.selector = selector |
| 233 | + } |
| 234 | + |
| 235 | + if (waitForReady) { |
| 236 | + rendererOptions.waitForReady = waitForReady |
| 237 | + } |
| 238 | + |
| 239 | + await rendererToUse(rendererOptions) |
| 240 | +} |
0 commit comments