mirror of
https://github.com/withastro/astro.git
synced 2024-12-16 21:46:22 -05:00
wip: add support for client-side Astro components
This commit is contained in:
parent
61f438fdcb
commit
1e0b670e28
9 changed files with 262 additions and 6 deletions
31
examples/minimal/src/components/counter.astro
Normal file
31
examples/minimal/src/components/counter.astro
Normal 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>
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
|
||||
import Counter from '../components/counter.astro'
|
||||
---
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
@ -9,5 +9,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<h1>Astro</h1>
|
||||
<Counter count={0} client:load />
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
@ -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",
|
||||
|
|
15
packages/astro/src/runtime/client/client.ts
Normal file
15
packages/astro/src/runtime/client/client.ts
Normal 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);
|
||||
}
|
|
@ -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<string | number, any>, 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}</${component}>`);
|
||||
}
|
||||
|
||||
if (Component === null && !_props['client:only']) {
|
||||
|
|
105
packages/astro/src/runtime/server/worker.ts
Normal file
105
packages/astro/src/runtime/server/worker.ts
Normal 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);
|
||||
}
|
|
@ -15,6 +15,10 @@ const configCache = new WeakMap<AstroConfig, CompilationCache>();
|
|||
type CompileResult = TransformResult & { rawCSSDeps: Set<string> };
|
||||
|
||||
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)
|
||||
// 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<string, string>) => {
|
||||
|
|
|
@ -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 = /\<script[^>]*\@component[^>]*\>([\s\S]*)\<\/script\>/mi;
|
||||
const EXTRACT_COMPONENT_SCRIPT = /(?<=\<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,
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue