Compare commits

...

1 commit

Author SHA1 Message Date
Nate Moore
1e0b670e28 wip: add support for client-side Astro components 2022-02-14 10:13:48 -06:00
9 changed files with 262 additions and 6 deletions

View file

@ -0,0 +1,31 @@
---
const { count } = Astro.props;
---
<div>
<button>-</button>
<p>{count}</p>
<button>+</button>
</div>
<script @component>
export class Component extends HTMLElement {
handleEvent(event) {
if (event.type !== 'click') return;
if (event.target.textContent === '+') {
this.count += 1;
} else if (event.target.textContent === '-') {
this.count -= 1;
}
}
connectedCallback() {
this.addEventListener('click', this)
}
disconnectedCallback() {
this.removeEventListener('click', this)
}
}
</script>

View file

@ -1,5 +1,5 @@
--- ---
import Counter from '../components/counter.astro'
--- ---
<html lang="en"> <html lang="en">
<head> <head>
@ -9,5 +9,6 @@
</head> </head>
<body> <body>
<h1>Astro</h1> <h1>Astro</h1>
<Counter count={0} client:load />
</body> </body>
</html> </html>

View file

@ -83,6 +83,7 @@
"htmlparser2": "^7.1.2", "htmlparser2": "^7.1.2",
"kleur": "^4.1.4", "kleur": "^4.1.4",
"magic-string": "^0.25.7", "magic-string": "^0.25.7",
"micromorph": "^0.0.2",
"mime": "^3.0.0", "mime": "^3.0.0",
"morphdom": "^2.6.1", "morphdom": "^2.6.1",
"parse5": "^6.0.1", "parse5": "^6.0.1",

View file

@ -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}</${from.localName}>`, 'text/html').body.children[0];
return diff(from, to);
}

View file

@ -126,6 +126,11 @@ function formatList(values: string[]): string {
return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`; 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<string | number, any>, slots: any = {}) { export async function renderComponent(result: SSRResult, displayName: string, Component: unknown, _props: Record<string | number, any>, slots: any = {}) {
Component = await Component; Component = await Component;
const children = await renderSlot(result, slots?.default); const children = await renderSlot(result, slots?.default);
@ -135,10 +140,67 @@ export async function renderComponent(result: SSRResult, displayName: string, Co
} }
if (Component && (Component as any).isAstroComponentFactory) { 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); const output = await renderToString(result, Component as any, _props, slots);
if (!metadata.hydrate) {
return unescapeHTML(output); 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}</${component}>`);
}
if (Component === null && !_props['client:only']) { if (Component === null && !_props['client:only']) {
throw new Error(`Unable to render ${displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?`); throw new Error(`Unable to render ${displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?`);
} }

View file

@ -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<any> {
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<typeof AstroComponent>) {
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);
}

View file

@ -15,6 +15,10 @@ const configCache = new WeakMap<AstroConfig, CompilationCache>();
type CompileResult = TransformResult & { rawCSSDeps: Set<string> }; type CompileResult = TransformResult & { rawCSSDeps: Set<string> };
async function compile(config: AstroConfig, filename: string, source: string, viteTransform: TransformHook, opts: { ssr: boolean }): Promise<CompileResult> { async function compile(config: AstroConfig, filename: string, source: string, viteTransform: TransformHook, opts: { ssr: boolean }): Promise<CompileResult> {
const isWorker = filename.endsWith('?worker_file');
if (isWorker) {
filename = filename.replace(/\?worker_file$/, '');
}
// pages and layouts should be transformed as full documents (implicit <head> <body> etc) // pages and layouts should be transformed as full documents (implicit <head> <body> etc)
// everything else is treated as a fragment // everything else is treated as a fragment
const filenameURL = new URL(`file://${filename}`); const filenameURL = new URL(`file://${filename}`);
@ -35,7 +39,7 @@ async function compile(config: AstroConfig, filename: string, source: string, vi
site: config.buildOptions.site, site: config.buildOptions.site,
sourcefile: filename, sourcefile: filename,
sourcemap: 'both', sourcemap: 'both',
internalURL: 'astro/internal', internalURL: isWorker ? 'astro/internal/worker.js' : 'astro/internal',
experimentalStaticExtraction: config.buildOptions.experimentalStaticBuild, experimentalStaticExtraction: config.buildOptions.experimentalStaticBuild,
// TODO add experimental flag here // TODO add experimental flag here
preprocessStyle: async (value: string, attrs: Record<string, string>) => { preprocessStyle: async (value: string, attrs: Record<string, string>) => {

View file

@ -17,6 +17,10 @@ interface AstroPluginOptions {
logging: LogOptions; logging: LogOptions;
} }
const isValidAstroFile = (id: string) => id.endsWith('.astro') || id.endsWith('.astro?worker_file') || id.endsWith('.astro?component_script')
const COMPONENT_SCRIPT = /\<script[^>]*\@component[^>]*\>([\s\S]*)\<\/script\>/mi;
const EXTRACT_COMPONENT_SCRIPT = /(?<=\<script[^>]*\@component[^>]*\>)[\s\S]+(?=\<\/script\>)/mi;
/** Transform .astro files for Vite */ /** Transform .astro files for Vite */
export default function astro({ config, logging }: AstroPluginOptions): vite.Plugin { export default function astro({ config, logging }: AstroPluginOptions): vite.Plugin {
function normalizeFilename(filename: string) { function normalizeFilename(filename: string) {
@ -106,16 +110,44 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
return null; return null;
}, },
async transform(source, id, opts) { async transform(source, id, opts) {
if (!id.endsWith('.astro')) { if (!isValidAstroFile(id)) {
return; return;
} }
try { try {
const transformResult = await cachedCompilation(config, id, source, viteTransform, { ssr: Boolean(opts?.ssr) }); 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. // Compile all TypeScript to JavaScript.
// Also, catches invalid JS/TS in the compiled output before returning. // 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', loader: 'ts',
sourcemap: 'external', sourcemap: 'external',
sourcefile: id, sourcefile: id,

View file

@ -5930,6 +5930,11 @@ micromatch@^4.0.2, micromatch@^4.0.4:
braces "^3.0.1" braces "^3.0.1"
picomatch "^2.2.3" 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: mime@1.6.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"