From 1e0b670e28f3094876cfe9d43158f9b402cbe810 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Mon, 14 Feb 2022 10:13:22 -0600 Subject: [PATCH] wip: add support for client-side Astro components --- examples/minimal/src/components/counter.astro | 31 ++++++ examples/minimal/src/pages/index.astro | 5 +- packages/astro/package.json | 1 + packages/astro/src/runtime/client/client.ts | 15 +++ packages/astro/src/runtime/server/index.ts | 64 ++++++++++- packages/astro/src/runtime/server/worker.ts | 105 ++++++++++++++++++ .../astro/src/vite-plugin-astro/compile.ts | 6 +- packages/astro/src/vite-plugin-astro/index.ts | 36 +++++- yarn.lock | 5 + 9 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 examples/minimal/src/components/counter.astro create mode 100644 packages/astro/src/runtime/client/client.ts create mode 100644 packages/astro/src/runtime/server/worker.ts diff --git a/examples/minimal/src/components/counter.astro b/examples/minimal/src/components/counter.astro new file mode 100644 index 0000000000..dfac91a2b4 --- /dev/null +++ b/examples/minimal/src/components/counter.astro @@ -0,0 +1,31 @@ +--- +const { count } = Astro.props; +--- + +
+ +

{count}

+ +
+ + diff --git a/examples/minimal/src/pages/index.astro b/examples/minimal/src/pages/index.astro index 25f796bf17..5ca2ac8a90 100644 --- a/examples/minimal/src/pages/index.astro +++ b/examples/minimal/src/pages/index.astro @@ -1,5 +1,5 @@ --- - +import Counter from '../components/counter.astro' --- @@ -9,5 +9,6 @@

Astro

+ - \ No newline at end of file + diff --git a/packages/astro/package.json b/packages/astro/package.json index fa5cb65836..b43a3d4896 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -83,6 +83,7 @@ "htmlparser2": "^7.1.2", "kleur": "^4.1.4", "magic-string": "^0.25.7", + "micromorph": "^0.0.2", "mime": "^3.0.0", "morphdom": "^2.6.1", "parse5": "^6.0.1", diff --git a/packages/astro/src/runtime/client/client.ts b/packages/astro/src/runtime/client/client.ts new file mode 100644 index 0000000000..0e8e8780df --- /dev/null +++ b/packages/astro/src/runtime/client/client.ts @@ -0,0 +1,15 @@ +import diff from 'micromorph'; + +const serialize = (el: Element) => { + let str = ''; + for (const attr of el.attributes) { + str += ` ${attr.name}="${attr.value}"`; + } + return str; +} + +const p = new DOMParser(); +export function patch(from: Element, children: string) { + const to = p.parseFromString(`<${from.localName} ${serialize(from)}>${children}`, 'text/html').body.children[0]; + return diff(from, to); +} diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 93c9084166..2896a21361 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -126,6 +126,11 @@ function formatList(values: string[]): string { return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`; } +const kebabCase = (str: string) => str + .replace(/([a-z])([A-Z])/g, "$1-$2") + .replace(/[\s_]+/g, '-') + .toLowerCase() + export async function renderComponent(result: SSRResult, displayName: string, Component: unknown, _props: Record, slots: any = {}) { Component = await Component; const children = await renderSlot(result, slots?.default); @@ -135,8 +140,65 @@ export async function renderComponent(result: SSRResult, displayName: string, Co } if (Component && (Component as any).isAstroComponentFactory) { + const { hydration, props } = extractDirectives(_props); + const metadata: AstroComponentMetadata = { displayName }; + if (hydration) { + metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate']; + metadata.hydrateArgs = hydration.value; + metadata.componentExport = hydration.componentExport; + metadata.componentUrl = hydration.componentUrl; + } const output = await renderToString(result, Component as any, _props, slots); - return unescapeHTML(output); + + if (!metadata.hydrate) { + return unescapeHTML(output); + } + + let component = kebabCase(displayName); + if (!component.includes('-')) { + component = `my-${component}`; + } + const observedAttributes = Object.keys(props); + // TODO: this should probably be automatically generated based on a query param so we have the full component AST to work with + result.scripts.add({ + props: { type: 'module', 'data-astro-component-hydration': true }, + // TODO: shared_worker? + children: ` + import { patch } from 'astro/client/client.js'; + // TODO: fix Vite issue with multiple \`default\` exports + import { Component } from "${metadata.componentUrl}?component_script"; + import ComponentWorker from "${metadata.componentUrl}?worker"; + + customElements.define("${component}", class extends Component { + // Possibly expose getters and setters for observedAttributes? + ${observedAttributes.map(attr => { + return ` + get ${attr}() { + return JSON.parse(this.getAttribute("${attr}")); + } + set ${attr}(value) { + this.setAttribute("${attr}", JSON.stringify(value)); + } + ` + })} + constructor(...args) { + super(...args); + this.worker = new ComponentWorker(); + this.worker.onmessage = (e) => { + if (e.data[0] === 'result') { + patch(this, e.data[1]); + } + } + } + static get observedAttributes() { + return ${JSON.stringify(observedAttributes)}; + } + attributeChangedCallback(name, oldValue, newValue) { + this.worker.postMessage(['attributeChangedCallback', name, oldValue, newValue]); + } + })`, + }); + return unescapeHTML(`<${component} ${spreadAttributes(props)}>${output}`); } if (Component === null && !_props['client:only']) { diff --git a/packages/astro/src/runtime/server/worker.ts b/packages/astro/src/runtime/server/worker.ts new file mode 100644 index 0000000000..43285e17e9 --- /dev/null +++ b/packages/astro/src/runtime/server/worker.ts @@ -0,0 +1,105 @@ +import { escapeHTML, UnescapedString, unescapeHTML } from './escape.js'; +export { escapeHTML, unescapeHTML } from './escape.js'; + +export const createMetadata = () => ({}); +export const createComponent = (fn: any) => fn; + +async function _render(child: any): Promise { + child = await child; + if (child instanceof UnescapedString) { + return child; + } else if (Array.isArray(child)) { + return unescapeHTML((await Promise.all(child.map((value) => _render(value)))).join('')); + } else if (typeof child === 'function') { + // Special: If a child is a function, call it automatically. + // This lets you do {() => ...} without the extra boilerplate + // of wrapping it in a function and calling it. + return _render(child()); + } else if (typeof child === 'string') { + return escapeHTML(child, { deprecated: true }); + } else if (!child && child !== 0) { + // do nothing, safe to ignore falsey values. + } + // Add a comment explaining why each of these are needed. + // Maybe create clearly named function for what this is doing. + else if (child instanceof AstroComponent || Object.prototype.toString.call(child) === '[object AstroComponent]') { + return unescapeHTML(await renderAstroComponent(child)); + } else { + return child; + } +} + +// This is used to create the top-level Astro global; the one that you can use +// Inside of getStaticPaths. +export function createAstro(filePathname: string, _site: string, projectRootStr: string): AstroGlobalPartial { + const site = new URL(_site); + const url = new URL(filePathname, site); + const projectRoot = new URL(projectRootStr); + const fetchContent = () => {}; + return { + site, + fetchContent, + // INVESTIGATE is there a use-case for multi args? + resolve(...segments: string[]) { + let resolved = segments.reduce((u, segment) => new URL(segment, u), url).pathname; + // When inside of project root, remove the leading path so you are + // left with only `/src/images/tower.png` + if (resolved.startsWith(projectRoot.pathname)) { + resolved = '/' + resolved.substr(projectRoot.pathname.length); + } + return resolved; + }, + }; +} + +export async function renderAstroComponent(component: InstanceType) { + let template = []; + + for await (const value of component) { + if (value || value === 0) { + template.push(value); + } + } + + return unescapeHTML(await _render(template)); +} + + +// The return value when rendering a component. +// This is the result of calling render(), should this be named to RenderResult or...? +export class AstroComponent { + private htmlParts: TemplateStringsArray; + private expressions: any[]; + + constructor(htmlParts: TemplateStringsArray, expressions: any[]) { + this.htmlParts = htmlParts; + this.expressions = expressions; + } + + get [Symbol.toStringTag]() { + return 'AstroComponent'; + } + + *[Symbol.iterator]() { + const { htmlParts, expressions } = this; + + for (let i = 0; i < htmlParts.length; i++) { + const html = htmlParts[i]; + const expression = expressions[i]; + + yield _render(unescapeHTML(html)); + yield _render(expression); + } + } +} + +export async function render(htmlParts: TemplateStringsArray, ...expressions: any[]) { + return new AstroComponent(htmlParts, expressions); +} + +// Calls a component and renders it into a string of HTML +export async function renderToString(result: any, componentFactory: any, props: any, children: any) { + const Component = await componentFactory(result, props, children); + let template = await renderAstroComponent(Component); + return unescapeHTML(template); +} diff --git a/packages/astro/src/vite-plugin-astro/compile.ts b/packages/astro/src/vite-plugin-astro/compile.ts index 7eb0eddc43..dbb3292729 100644 --- a/packages/astro/src/vite-plugin-astro/compile.ts +++ b/packages/astro/src/vite-plugin-astro/compile.ts @@ -15,6 +15,10 @@ const configCache = new WeakMap(); type CompileResult = TransformResult & { rawCSSDeps: Set }; async function compile(config: AstroConfig, filename: string, source: string, viteTransform: TransformHook, opts: { ssr: boolean }): Promise { + const isWorker = filename.endsWith('?worker_file'); + if (isWorker) { + filename = filename.replace(/\?worker_file$/, ''); + } // pages and layouts should be transformed as full documents (implicit etc) // everything else is treated as a fragment const filenameURL = new URL(`file://${filename}`); @@ -35,7 +39,7 @@ async function compile(config: AstroConfig, filename: string, source: string, vi site: config.buildOptions.site, sourcefile: filename, sourcemap: 'both', - internalURL: 'astro/internal', + internalURL: isWorker ? 'astro/internal/worker.js' : 'astro/internal', experimentalStaticExtraction: config.buildOptions.experimentalStaticBuild, // TODO add experimental flag here preprocessStyle: async (value: string, attrs: Record) => { diff --git a/packages/astro/src/vite-plugin-astro/index.ts b/packages/astro/src/vite-plugin-astro/index.ts index 5f6138b048..2c9899c5a2 100644 --- a/packages/astro/src/vite-plugin-astro/index.ts +++ b/packages/astro/src/vite-plugin-astro/index.ts @@ -17,6 +17,10 @@ interface AstroPluginOptions { logging: LogOptions; } +const isValidAstroFile = (id: string) => id.endsWith('.astro') || id.endsWith('.astro?worker_file') || id.endsWith('.astro?component_script') +const COMPONENT_SCRIPT = /\]*\@component[^>]*\>([\s\S]*)\<\/script\>/mi; +const EXTRACT_COMPONENT_SCRIPT = /(?<=\]*\@component[^>]*\>)[\s\S]+(?=\<\/script\>)/mi; + /** Transform .astro files for Vite */ export default function astro({ config, logging }: AstroPluginOptions): vite.Plugin { function normalizeFilename(filename: string) { @@ -106,16 +110,44 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu return null; }, async transform(source, id, opts) { - if (!id.endsWith('.astro')) { + if (!isValidAstroFile(id)) { return; } try { const transformResult = await cachedCompilation(config, id, source, viteTransform, { ssr: Boolean(opts?.ssr) }); + let result = transformResult.code; + if (id.endsWith('?worker_file')) { + result += `\nimport { renderToString } from 'astro/internal/worker.js'; +let prevResult = ''; +onmessage = async function (e) { + if (e.data[0] === 'attributeChangedCallback') { + const [_, name, oldValue, newValue] = e.data; + if (oldValue === newValue) return; + const result = await renderToString({ createAstro(_, props, slots) { return { props, slots } } }, $$Counter, { [name]: JSON.parse(newValue) }, {}); + if (result !== prevResult) { + self.postMessage(['result', result]) + prevResult = result; + } + } +}` + } + if (id.endsWith('?component_script')) { + if (COMPONENT_SCRIPT.test(result)) { + const script = result.match(EXTRACT_COMPONENT_SCRIPT)?.[0]; + result = (script as string); + } else { + result = 'export class Component extends HTMLElement {}'; + } + } else { + if (COMPONENT_SCRIPT.test(result)) { + result = result.replace(COMPONENT_SCRIPT, ''); + } + } // Compile all TypeScript to JavaScript. // Also, catches invalid JS/TS in the compiled output before returning. - const { code, map } = await esbuild.transform(transformResult.code, { + const { code, map } = await esbuild.transform(result, { loader: 'ts', sourcemap: 'external', sourcefile: id, diff --git a/yarn.lock b/yarn.lock index 4178e138e4..883f37fec9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5930,6 +5930,11 @@ micromatch@^4.0.2, micromatch@^4.0.4: braces "^3.0.1" picomatch "^2.2.3" +micromorph@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/micromorph/-/micromorph-0.0.2.tgz#5cfbaea66ae5fb8629c8041897e88d9e827afc2f" + integrity sha512-hfy/OA8rtwI/vPRm4L6a3/u6uDvqsPmTok7pPmtfv2a7YfaTVfxd9HX2Kdn/SZ8rGMKkKVJ9A0WcBnzs0bjLXw== + mime@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"