Implements low-level custom element support (#587)

* Implements low-level custom element support

* Changes based on self review

* Adds a changeset

* Polyfills are added even when not hydrating

* Remove hydrationMethod option

Punting on this idea until it's really needed.
This commit is contained in:
Matthew Phillips 2021-07-01 08:42:07 -04:00 committed by GitHub
parent 81ea010906
commit 6a660f1b08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 2142 additions and 1736 deletions

View file

@ -0,0 +1,6 @@
---
'astro': patch
'@astrojs/parser': patch
---
Adds low-level custom element support that renderers can use to enable server side rendering. This will be used in renderers such as a Lit renderer.

View file

@ -100,10 +100,12 @@ Additionally, this entrypoint can define a [Snowpack plugin](https://www.snowpac
export default { export default {
name: '@astrojs/renderer-xxx', // the renderer name name: '@astrojs/renderer-xxx', // the renderer name
client: './client.js', // relative path to the client entrypoint client: './client.js', // relative path to the client entrypoint
server: './server.js', // relative path to the server entrypoint server: './server.js', // optional, relative path to the server entrypoint
snowpackPlugin: '@snowpack/plugin-xxx', // optional, the name of a snowpack plugin to inject snowpackPlugin: '@snowpack/plugin-xxx', // optional, the name of a snowpack plugin to inject
snowpackPluginOptions: { example: true }, // optional, any options to be forwarded to the snowpack plugin snowpackPluginOptions: { example: true }, // optional, any options to be forwarded to the snowpack plugin
knownEntrypoint: ['framework'], // optional, entrypoint modules that will be used by compiled source knownEntrypoint: ['framework'], // optional, entrypoint modules that will be used by compiled source
external: ['dep'] // optional, dependencies that should not be built by snowpack
polyfills: ['./shadow-dom-polyfill.js'] // optional, module scripts that should be loaded before client hydration.
}; };
``` ```

View file

@ -29,7 +29,8 @@
"scripts", "scripts",
"www", "www",
"docs-www", "docs-www",
"packages/astro/test/fixtures/builtins/packages/*" "packages/astro/test/fixtures/builtins/packages/*",
"packages/astro/test/fixtures/custom-elements/my-component-lib"
], ],
"volta": { "volta": {
"node": "14.16.1", "node": "14.16.1",
@ -37,7 +38,8 @@
"yarn": "1.22.10" "yarn": "1.22.10"
}, },
"dependencies": { "dependencies": {
"@changesets/cli": "^2.16.0" "@changesets/cli": "^2.16.0",
"camel-case": "^4.1.2"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.22.0", "@typescript-eslint/eslint-plugin": "^4.22.0",

View file

@ -1,2 +1,3 @@
export * from './interfaces'; export * from './interfaces';
export * from './parse/utils/features';
export { default as parse } from './parse/index.js'; export { default as parse } from './parse/index.js';

View file

@ -106,6 +106,9 @@ export interface Ast {
css: Style; css: Style;
module: Script; module: Script;
// instance: Script; // instance: Script;
meta: {
features: number;
}
} }
export interface Warning { export interface Warning {

View file

@ -29,6 +29,7 @@ export class Parser {
js: Script[] = []; js: Script[] = [];
meta_tags = {}; meta_tags = {};
last_auto_closed_tag?: LastAutoClosedTag; last_auto_closed_tag?: LastAutoClosedTag;
feature_flags: 0
constructor(template: string, options: ParserOptions) { constructor(template: string, options: ParserOptions) {
if (typeof template !== 'string') { if (typeof template !== 'string') {
@ -266,5 +267,8 @@ export default function parse(template: string, options: ParserOptions = {}): As
css: parser.css[0], css: parser.css[0],
// instance: instance_scripts[0], // instance: instance_scripts[0],
module: astro_scripts[0], module: astro_scripts[0],
meta: {
features: parser.feature_flags
}
}; };
} }

View file

@ -8,6 +8,7 @@ import { Parser } from '../index.js';
import { Directive, DirectiveType, TemplateNode, Text } from '../../interfaces.js'; import { Directive, DirectiveType, TemplateNode, Text } from '../../interfaces.js';
import fuzzymatch from '../../utils/fuzzymatch.js'; import fuzzymatch from '../../utils/fuzzymatch.js';
import list from '../../utils/list.js'; import list from '../../utils/list.js';
import { FEATURE_CUSTOM_ELEMENT } from '../utils/features.js';
const valid_tag_name = /^\!?[a-zA-Z]{1,}:?[a-zA-Z0-9\-]*/; const valid_tag_name = /^\!?[a-zA-Z]{1,}:?[a-zA-Z0-9\-]*/;
@ -43,6 +44,7 @@ const SELF = /^astro:self(?=[\s/>])/;
const COMPONENT = /^astro:component(?=[\s/>])/; const COMPONENT = /^astro:component(?=[\s/>])/;
const SLOT = /^astro:fragment(?=[\s/>])/; const SLOT = /^astro:fragment(?=[\s/>])/;
const HEAD = /^head(?=[\s/>])/; const HEAD = /^head(?=[\s/>])/;
const CUSTOM_ELEMENT = /-/;
function parent_is_head(stack) { function parent_is_head(stack) {
let i = stack.length; let i = stack.length;
@ -54,6 +56,7 @@ function parent_is_head(stack) {
return false; return false;
} }
export default function tag(parser: Parser) { export default function tag(parser: Parser) {
const start = parser.index++; const start = parser.index++;
@ -77,6 +80,10 @@ export default function tag(parser: Parser) {
const name = read_tag_name(parser); const name = read_tag_name(parser);
if(CUSTOM_ELEMENT.test(name)) {
parser.feature_flags |= FEATURE_CUSTOM_ELEMENT;
}
if (meta_tags.has(name)) { if (meta_tags.has(name)) {
const slug = meta_tags.get(name).toLowerCase(); const slug = meta_tags.get(name).toLowerCase();
if (is_closing_tag) { if (is_closing_tag) {

View file

@ -0,0 +1,2 @@
export const FEATURE_CUSTOM_ELEMENT = 1 << 0;

View file

@ -65,6 +65,8 @@ export interface TransformResult {
css?: string; css?: string;
/** If this page exports a collection, the JS to be executed as a string */ /** If this page exports a collection, the JS to be executed as a string */
createCollection?: string; createCollection?: string;
hasCustomElements: boolean;
customElementCandidates: Map<string, string>;
} }
export interface CompileResult { export interface CompileResult {
@ -180,11 +182,11 @@ export interface ComponentInfo {
export type Components = Map<string, ComponentInfo>; export type Components = Map<string, ComponentInfo>;
type AsyncRendererComponentFn<U> = (Component: any, props: any, children: string | undefined) => Promise<U>; type AsyncRendererComponentFn<U> = (Component: any, props: any, children: string | undefined, options?: any) => Promise<U>;
export interface Renderer { export interface Renderer {
check: AsyncRendererComponentFn<boolean>; check: AsyncRendererComponentFn<boolean>;
renderToStaticMarkup: AsyncRendererComponentFn<{ renderToStaticMarkup: AsyncRendererComponentFn<{
html: string; html: string;
}>; }>;
} }

View file

@ -7,7 +7,7 @@ import 'source-map-support/register.js';
import eslexer from 'es-module-lexer'; import eslexer from 'es-module-lexer';
import esbuild from 'esbuild'; import esbuild from 'esbuild';
import path from 'path'; import path from 'path';
import { parse } from '@astrojs/parser'; import { parse, FEATURE_CUSTOM_ELEMENT } from '@astrojs/parser';
import { walk, asyncWalk } from 'estree-walker'; import { walk, asyncWalk } from 'estree-walker';
import _babelGenerator from '@babel/generator'; import _babelGenerator from '@babel/generator';
import babelParser from '@babel/parser'; import babelParser from '@babel/parser';
@ -17,12 +17,14 @@ import { error, warn, parseError } from '../../logger.js';
import { fetchContent } from './content.js'; import { fetchContent } from './content.js';
import { isFetchContent } from './utils.js'; import { isFetchContent } from './utils.js';
import { yellow } from 'kleur/colors'; import { yellow } from 'kleur/colors';
import { isComponentTag, positionAt } from '../utils.js'; import { isComponentTag, isCustomElementTag, positionAt } from '../utils.js';
import { renderMarkdown } from '@astrojs/markdown-support'; import { renderMarkdown } from '@astrojs/markdown-support';
import { camelCase } from 'camel-case';
import { transform } from '../transform/index.js'; import { transform } from '../transform/index.js';
import { PRISM_IMPORT } from '../transform/prism.js'; import { PRISM_IMPORT } from '../transform/prism.js';
import { nodeBuiltinsSet } from '../../node_builtins.js'; import { nodeBuiltinsSet } from '../../node_builtins.js';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { pathToFileURL } from 'url';
const traverse: typeof babelTraverse.default = (babelTraverse.default as any).default; const traverse: typeof babelTraverse.default = (babelTraverse.default as any).default;
@ -142,6 +144,13 @@ function generateAttributes(attrs: Record<string, string>): string {
return result + '}'; return result + '}';
} }
function getComponentUrl(astroConfig: AstroConfig, url: string, parentUrl: string | URL){
const componentExt = path.extname(url);
const ext = PlainExtensions.has(componentExt) ? '.js' : `${componentExt}.js`;
const outUrl = new URL(url, parentUrl);
return '/_astro/' + outUrl.href.replace(astroConfig.projectRoot.href, '').replace(/\.[^.]+$/, ext);
}
interface GetComponentWrapperOptions { interface GetComponentWrapperOptions {
filename: string; filename: string;
astroConfig: AstroConfig; astroConfig: AstroConfig;
@ -151,36 +160,43 @@ const PlainExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']);
/** Generate Astro-friendly component import */ /** Generate Astro-friendly component import */
function getComponentWrapper(_name: string, { url, importSpecifier }: ComponentInfo, opts: GetComponentWrapperOptions) { function getComponentWrapper(_name: string, { url, importSpecifier }: ComponentInfo, opts: GetComponentWrapperOptions) {
const { astroConfig, filename } = opts; const { astroConfig, filename } = opts;
const currFileUrl = new URL(`file://${filename}`);
const [name, kind] = _name.split(':'); const [name, kind] = _name.split(':');
const getComponentUrl = () => {
const componentExt = path.extname(url);
const ext = PlainExtensions.has(componentExt) ? '.js' : `${componentExt}.js`;
const outUrl = new URL(url, currFileUrl);
return '/_astro/' + outUrl.href.replace(astroConfig.projectRoot.href, '').replace(/\.[^.]+$/, ext);
};
const getComponentExport = () => {
switch (importSpecifier.type) {
case 'ImportDefaultSpecifier':
return { value: 'default' };
case 'ImportSpecifier': {
if (importSpecifier.imported.type === 'Identifier') {
return { value: importSpecifier.imported.name };
}
return { value: importSpecifier.imported.value };
}
case 'ImportNamespaceSpecifier': {
const [_, value] = name.split('.');
return { value };
}
}
};
const importInfo = kind ? { componentUrl: getComponentUrl(), componentExport: getComponentExport() } : {}; // Special flow for custom elements
return { if (isCustomElementTag(name)) {
wrapper: `__astro_component(${name}, ${JSON.stringify({ hydrate: kind, displayName: _name, ...importInfo })})`, return {
wrapperImport: `import {__astro_component} from 'astro/dist/internal/__astro_component.js';`, wrapper: `__astro_component(...__astro_element_registry.astroComponentArgs("${name}", ${JSON.stringify({ hydrate: kind, displayName: _name })}))`,
}; wrapperImports: [`import {AstroElementRegistry} from 'astro/dist/internal/element-registry.js';`,`import {__astro_component} from 'astro/dist/internal/__astro_component.js';`],
};
} else {
const getComponentExport = () => {
switch (importSpecifier.type) {
case 'ImportDefaultSpecifier':
return { value: 'default' };
case 'ImportSpecifier': {
if (importSpecifier.imported.type === 'Identifier') {
return { value: importSpecifier.imported.name };
}
return { value: importSpecifier.imported.value };
}
case 'ImportNamespaceSpecifier': {
const [_, value] = name.split('.');
return { value };
}
}
};
const importInfo = kind ? {
componentUrl: getComponentUrl(astroConfig, url, pathToFileURL(filename)),
componentExport: getComponentExport()
} : {};
return {
wrapper: `__astro_component(${name}, ${JSON.stringify({ hydrate: kind, displayName: _name, ...importInfo })})`,
wrapperImports: [`import {__astro_component} from 'astro/dist/internal/__astro_component.js';`],
};
}
} }
/** /**
@ -251,19 +267,22 @@ interface CompileResult {
} }
interface CodegenState { interface CodegenState {
filename: string;
fileID: string;
components: Components; components: Components;
css: string[]; css: string[];
filename: string;
fileID: string;
markers: { markers: {
insideMarkdown: boolean | Record<string, any>; insideMarkdown: boolean | Record<string, any>;
}; };
exportStatements: Set<string>; exportStatements: Set<string>;
importStatements: Set<string>; importStatements: Set<string>;
customElementCandidates: Map<string, string>;
} }
/** Compile/prepare Astro frontmatter scripts */ /** Compile/prepare Astro frontmatter scripts */
function compileModule(module: Script, state: CodegenState, compileOptions: CompileOptions): CompileResult { function compileModule(ast: Ast, module: Script, state: CodegenState, compileOptions: CompileOptions): CompileResult {
const { astroConfig } = compileOptions;
const { filename } = state;
const componentImports: ImportDeclaration[] = []; const componentImports: ImportDeclaration[] = [];
const componentProps: VariableDeclarator[] = []; const componentProps: VariableDeclarator[] = [];
const componentExports: ExportNamedDeclaration[] = []; const componentExports: ExportNamedDeclaration[] = [];
@ -373,7 +392,14 @@ function compileModule(module: Script, state: CodegenState, compileOptions: Comp
}); });
} }
const { start, end } = componentImport; const { start, end } = componentImport;
state.importStatements.add(module.content.slice(start || undefined, end || undefined)); if(ast.meta.features & FEATURE_CUSTOM_ELEMENT && componentImport.specifiers.length === 0) {
// Add possible custom element, but only if the AST says there are custom elements.
const moduleImportName = camelCase(importUrl+ 'Module');
state.importStatements.add(`import * as ${moduleImportName} from '${importUrl}';\n`);
state.customElementCandidates.set(moduleImportName, getComponentUrl(astroConfig, importUrl, pathToFileURL(filename)));
} else {
state.importStatements.add(module.content.slice(start || undefined, end || undefined));
}
} }
// TODO: actually expose componentExports other than __layout and __content // TODO: actually expose componentExports other than __layout and __content
@ -385,7 +411,6 @@ function compileModule(module: Script, state: CodegenState, compileOptions: Comp
if (componentProps.length > 0) { if (componentProps.length > 0) {
const shortname = path.posix.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename); const shortname = path.posix.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename);
const props = componentProps.map((prop) => (prop.id as Identifier)?.name).filter((v) => v); const props = componentProps.map((prop) => (prop.id as Identifier)?.name).filter((v) => v);
console.log();
warn( warn(
compileOptions.logging, compileOptions.logging,
shortname, shortname,
@ -627,7 +652,7 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
const [componentNamespace] = componentName.split('.'); const [componentNamespace] = componentName.split('.');
componentInfo = components.get(componentNamespace); componentInfo = components.get(componentNamespace);
} }
if (!componentInfo) { if (!componentInfo && !isCustomElementTag(componentName)) {
throw new Error(`Unknown Component: ${componentName}`); throw new Error(`Unknown Component: ${componentName}`);
} }
if (componentName === 'Markdown') { if (componentName === 'Markdown') {
@ -643,9 +668,11 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
curr = 'markdown'; curr = 'markdown';
return; return;
} }
const { wrapper, wrapperImport } = getComponentWrapper(name, componentInfo, { astroConfig, filename }); const { wrapper, wrapperImports } = getComponentWrapper(name, componentInfo ?? ({} as any), { astroConfig, filename });
if (wrapperImport) { if (wrapperImports) {
importStatements.add(wrapperImport); for(let wrapperImport of wrapperImports) {
importStatements.add(wrapperImport);
}
} }
if (curr === 'markdown') { if (curr === 'markdown') {
await pushMarkdownToBuffer(); await pushMarkdownToBuffer();
@ -794,9 +821,10 @@ export async function codegen(ast: Ast, { compileOptions, filename, fileID }: Co
}, },
importStatements: new Set(), importStatements: new Set(),
exportStatements: new Set(), exportStatements: new Set(),
customElementCandidates: new Map()
}; };
const { script, createCollection } = compileModule(ast.module, state, compileOptions); const { script, createCollection } = compileModule(ast, ast.module, state, compileOptions);
compileCss(ast.css, state); compileCss(ast.css, state);
@ -809,5 +837,7 @@ export async function codegen(ast: Ast, { compileOptions, filename, fileID }: Co
html, html,
css: state.css.length ? state.css.join('\n\n') : undefined, css: state.css.length ? state.css.join('\n\n') : undefined,
createCollection, createCollection,
hasCustomElements: Boolean(ast.meta.features & FEATURE_CUSTOM_ELEMENT),
customElementCandidates: state.customElementCandidates
}; };
} }

View file

@ -106,7 +106,7 @@ interface CompileComponentOptions {
isPage?: boolean; isPage?: boolean;
} }
/** Compiles an Astro component */ /** Compiles an Astro component */
export async function compileComponent(source: string, { compileOptions, filename, projectRoot, isPage }: CompileComponentOptions): Promise<CompileResult> { export async function compileComponent(source: string, { compileOptions, filename, projectRoot }: CompileComponentOptions): Promise<CompileResult> {
const result = await transformFromSource(source, { compileOptions, filename, projectRoot }); const result = await transformFromSource(source, { compileOptions, filename, projectRoot });
const site = compileOptions.astroConfig.buildOptions.site || `http://localhost:${compileOptions.astroConfig.devOptions.port}`; const site = compileOptions.astroConfig.buildOptions.site || `http://localhost:${compileOptions.astroConfig.devOptions.port}`;
@ -116,6 +116,11 @@ import fetch from 'node-fetch';
// <script astro></script> // <script astro></script>
${result.imports.join('\n')} ${result.imports.join('\n')}
${result.hasCustomElements ? `
const __astro_element_registry = new AstroElementRegistry({
candidates: new Map([${Array.from(result.customElementCandidates).map(([identifier, url]) => `[${identifier}, '${url}']`).join(', ')}])
});
`.trim() : ''}
// \`__render()\`: Render the contents of the Astro module. // \`__render()\`: Render the contents of the Astro module.
import { h, Fragment } from 'astro/dist/internal/h.js'; import { h, Fragment } from 'astro/dist/internal/h.js';

View file

@ -1,6 +1,11 @@
/** Is the given string a custom-element tag? */
export function isCustomElementTag(tag: string) {
return /[-]/.test(tag);
}
/** Is the given string a valid component tag */ /** Is the given string a valid component tag */
export function isComponentTag(tag: string) { export function isComponentTag(tag: string) {
return /^[A-Z]/.test(tag) || /^[a-z]+\./.test(tag); return /^[A-Z]/.test(tag) || /^[a-z]+\./.test(tag) || isCustomElementTag(tag);
} }
export interface Position { export interface Position {

View file

@ -9,10 +9,13 @@ type RendererSnowpackPlugin = string | [string, any] | undefined;
interface RendererInstance { interface RendererInstance {
name: string; name: string;
options: any;
snowpackPlugin: RendererSnowpackPlugin; snowpackPlugin: RendererSnowpackPlugin;
client: string; client: string | null;
server: string; server: string;
knownEntrypoints: string[] | undefined; knownEntrypoints: string[] | undefined;
external: string[] | undefined;
polyfills: string[];
} }
const CONFIG_MODULE_BASE_NAME = '__astro_config.js'; const CONFIG_MODULE_BASE_NAME = '__astro_config.js';
@ -65,15 +68,25 @@ export class ConfigManager {
const rendererInstances = ( const rendererInstances = (
await Promise.all( await Promise.all(
rendererNames.map((rendererName) => { rendererNames.map(async (rendererName) => {
let _options: any = null;
if (Array.isArray(rendererName)) {
_options = rendererName[1];
rendererName = rendererName[0];
}
const entrypoint = pathToFileURL(resolveDependency(rendererName)).toString(); const entrypoint = pathToFileURL(resolveDependency(rendererName)).toString();
return import(entrypoint); const r = await import(entrypoint);
return {
raw: r.default,
options: _options
};
}) })
) )
).map(({ default: raw }, i) => { ).map(({ raw, options }, i) => {
const { name = rendererNames[i], client, server, snowpackPlugin: snowpackPluginName, snowpackPluginOptions } = raw; const { name = rendererNames[i], client, server, snowpackPlugin: snowpackPluginName, snowpackPluginOptions } = raw;
if (typeof client !== 'string') { if (typeof client !== 'string' && client != null) {
throw new Error(`Expected "client" from ${name} to be a relative path to the client-side renderer!`); throw new Error(`Expected "client" from ${name} to be a relative path to the client-side renderer!`);
} }
@ -92,12 +105,17 @@ export class ConfigManager {
throw new Error(`Expected the snowpackPlugin from ${name} to be a "string" but encountered "${typeof snowpackPluginName}"!`); throw new Error(`Expected the snowpackPlugin from ${name} to be a "string" but encountered "${typeof snowpackPluginName}"!`);
} }
const polyfillsNormalized = (raw.polyfills || []).map((p: string) => p.startsWith('.') ? path.join(name, p) : p);
return { return {
name, name,
options,
snowpackPlugin, snowpackPlugin,
client: path.join(name, raw.client), client: raw.client ? path.join(name, raw.client) : null,
server: path.join(name, raw.server), server: path.join(name, raw.server),
knownEntrypoints: raw.knownEntrypoints, knownEntrypoints: raw.knownEntrypoints,
external: raw.external,
polyfills: polyfillsNormalized
}; };
}); });
@ -107,16 +125,24 @@ export class ConfigManager {
async buildSource(contents: string): Promise<string> { async buildSource(contents: string): Promise<string> {
const renderers = await this.buildRendererInstances(); const renderers = await this.buildRendererInstances();
const rendererServerPackages = renderers.map(({ server }) => server); const rendererServerPackages = renderers.map(({ server }) => server);
const rendererClientPackages = await Promise.all(renderers.map(({ client }) => this.resolvePackageUrl(client))); const rendererClientPackages = await Promise.all(renderers.filter(({client}) => client).map(({ client }) => this.resolvePackageUrl(client!)));
const rendererPolyfills = await Promise.all(renderers.map(({ polyfills }) => Promise.all(polyfills.map(src => this.resolvePackageUrl(src)))));
const result = /* js */ `${rendererServerPackages.map((pkg, i) => `import __renderer_${i} from "${pkg}";`).join('\n')} const result = /* js */ `${rendererServerPackages.map((pkg, i) => `import __renderer_${i} from "${pkg}";`).join('\n')}
import { setRenderers } from 'astro/dist/internal/__astro_component.js'; import { setRenderers } from 'astro/dist/internal/__astro_component.js';
let rendererSources = [${rendererClientPackages.map((pkg) => `"${pkg}"`).join(', ')}]; let rendererInstances = [${renderers.map((r, i) => `{
let renderers = [${rendererServerPackages.map((_, i) => `__renderer_${i}`).join(', ')}]; source: ${rendererClientPackages[i] ? `"${rendererClientPackages[i]}"` : 'null'},
renderer: __renderer_${i},
options: ${r.options ? JSON.stringify(r.options) : 'null'},
polyfills: ${JSON.stringify(rendererPolyfills[i])}
}`).join(', ')}];
${contents} ${contents}
`; `;
return result; return result;
} }

View file

@ -1,6 +1,6 @@
declare function setRenderers(sources: string[], renderers: any[]): void; import type { RendererInstance } from '../internal/__astro_component';
declare let rendererSources: string[]; declare function setRenderers(instances: RendererInstance[]): void;
declare let renderers: any[]; declare let rendererInstances: RendererInstance[];
setRenderers(rendererSources, renderers); setRenderers(rendererInstances);

View file

@ -3,38 +3,64 @@ import hash from 'shorthash';
import { valueToEstree, Value } from 'estree-util-value-to-estree'; import { valueToEstree, Value } from 'estree-util-value-to-estree';
import { generate } from 'astring'; import { generate } from 'astring';
import * as astro from './renderer-astro'; import * as astro from './renderer-astro';
import * as astroHtml from './renderer-html';
// A more robust version alternative to `JSON.stringify` that can handle most values // A more robust version alternative to `JSON.stringify` that can handle most values
// see https://github.com/remcohaszing/estree-util-value-to-estree#readme // see https://github.com/remcohaszing/estree-util-value-to-estree#readme
const serialize = (value: Value) => generate(valueToEstree(value)); const serialize = (value: Value) => generate(valueToEstree(value));
let rendererSources: string[] = []; export interface RendererInstance {
let renderers: Renderer[] = []; source: string | null;
renderer: Renderer;
export function setRenderers(_rendererSources: string[], _renderers: Renderer[]) { options: any;
rendererSources = [''].concat(_rendererSources); polyfills: string[];
renderers = [astro as Renderer].concat(_renderers);
} }
const rendererCache = new WeakMap(); const astroRendererInstance: RendererInstance = {
source: '',
renderer: astro as Renderer,
options: null,
polyfills: []
};
const astroHtmlRendererInstance: RendererInstance = {
source: '',
renderer: astroHtml as Renderer,
options: null,
polyfills: []
};
let rendererInstances: RendererInstance[] = [];
export function setRenderers(_rendererInstances: RendererInstance[]) {
rendererInstances = [astroRendererInstance].concat(_rendererInstances);
}
function isCustomElementTag(name: string | Function) {
return typeof name === 'string' && /-/.test(name);
}
const rendererCache = new Map<any, RendererInstance>();
/** For a given component, resolve the renderer. Results are cached if this instance is encountered again */ /** For a given component, resolve the renderer. Results are cached if this instance is encountered again */
async function resolveRenderer(Component: any, props: any = {}, children?: string) { async function resolveRenderer(Component: any, props: any = {}, children?: string): Promise<RendererInstance | undefined> {
if (rendererCache.has(Component)) { if (rendererCache.has(Component)) {
return rendererCache.get(Component); return rendererCache.get(Component)!;
} }
const errors: Error[] = []; const errors: Error[] = [];
for (const __renderer of renderers) { for (const instance of rendererInstances) {
const { renderer, options } = instance;
// Yes, we do want to `await` inside of this loop! // Yes, we do want to `await` inside of this loop!
// __renderer.check can't be run in parallel, it // __renderer.check can't be run in parallel, it
// returns the first match and skips any subsequent checks // returns the first match and skips any subsequent checks
try { try {
const shouldUse: boolean = await __renderer.check(Component, props, children); const shouldUse: boolean = await renderer.check(Component, props, children, options);
if (shouldUse) { if (shouldUse) {
rendererCache.set(Component, __renderer); rendererCache.set(Component, instance);
return __renderer; return instance;
} }
} catch (err) { } catch (err) {
errors.push(err); errors.push(err);
@ -47,26 +73,39 @@ async function resolveRenderer(Component: any, props: any = {}, children?: strin
} }
} }
interface AstroComponentProps { export interface AstroComponentProps {
displayName: string; displayName: string;
hydrate?: 'load' | 'idle' | 'visible'; hydrate?: 'load' | 'idle' | 'visible';
componentUrl?: string; componentUrl?: string;
componentExport?: { value: string; namespace?: boolean }; componentExport?: { value: string; namespace?: boolean };
} }
/** For hydrated components, generate a <script type="module"> to load the component */ interface HydrateScriptOptions {
async function generateHydrateScript({ renderer, astroId, props }: any, { hydrate, componentUrl, componentExport }: Required<AstroComponentProps>) { instance: RendererInstance;
const rendererSource = rendererSources[renderers.findIndex((r) => r === renderer)]; astroId: string;
props: any;
}
const script = `<script type="module"> /** For hydrated components, generate a <script type="module"> to load the component */
async function generateHydrateScript({ instance, astroId, props }: HydrateScriptOptions, { hydrate, componentUrl, componentExport }: Required<AstroComponentProps>) {
const { source } = instance;
const hydrationSource = source ? `
const [{ ${componentExport.value}: Component }, { default: hydrate }] = await Promise.all([import("${componentUrl}"), import("${source}")]);
return (el, children) => hydrate(el)(Component, ${serialize(props)}, children);
`.trim() : `
await import("${componentUrl}");
return () => {};
`.trim()
const hydrationScript = `<script type="module">
import setup from '/_astro_frontend/hydrate/${hydrate}.js'; import setup from '/_astro_frontend/hydrate/${hydrate}.js';
setup("${astroId}", async () => { setup("${astroId}", async () => {
const [{ ${componentExport.value}: Component }, { default: hydrate }] = await Promise.all([import("${componentUrl}"), import("${rendererSource}")]); ${hydrationSource}
return (el, children) => hydrate(el)(Component, ${serialize(props)}, children);
}); });
</script>`; </script>`;
return script; return hydrationScript;
} }
const getComponentName = (Component: any, componentProps: any) => { const getComponentName = (Component: any, componentProps: any) => {
@ -85,25 +124,35 @@ const getComponentName = (Component: any, componentProps: any) => {
export const __astro_component = (Component: any, componentProps: AstroComponentProps = {} as any) => { export const __astro_component = (Component: any, componentProps: AstroComponentProps = {} as any) => {
if (Component == null) { if (Component == null) {
throw new Error(`Unable to render ${componentProps.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 ${componentProps.displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?`);
} else if (typeof Component === 'string') { } else if (typeof Component === 'string' && !isCustomElementTag(Component)) {
throw new Error(`Astro is unable to render ${componentProps.displayName}!\nIs there a renderer to handle this type of component defined in your Astro config?`); throw new Error(`Astro is unable to render ${componentProps.displayName}!\nIs there a renderer to handle this type of component defined in your Astro config?`);
} }
return async (props: any, ..._children: string[]) => { return async (props: any, ..._children: string[]) => {
const children = _children.join('\n'); const children = _children.join('\n');
let renderer = await resolveRenderer(Component, props, children); let instance = await resolveRenderer(Component, props, children);
if (!renderer) { if (!instance) {
// If the user only specifies a single renderer, but the check failed if(isCustomElementTag(Component)) {
// for some reason... just default to their preferred renderer. instance = astroHtmlRendererInstance;
renderer = rendererSources.length === 2 ? renderers[1] : null; } else {
// If the user only specifies a single renderer, but the check failed
// for some reason... just default to their preferred renderer.
instance = rendererInstances.length === 2 ? rendererInstances[1] : undefined;
}
if (!renderer) { if (!instance) {
const name = getComponentName(Component, componentProps); const name = getComponentName(Component, componentProps);
throw new Error(`No renderer found for ${name}! Did you forget to add a renderer to your Astro config?`); throw new Error(`No renderer found for ${name}! Did you forget to add a renderer to your Astro config?`);
} }
} }
const { html } = await renderer.renderToStaticMarkup(Component, props, children); let { html } = await instance.renderer.renderToStaticMarkup(Component, props, children, instance.options);
if(instance.polyfills.length) {
let polyfillScripts = instance.polyfills.map(src => `<script type="module" src="${src}"></script>`).join('');
html = html + polyfillScripts;
}
// If we're NOT hydrating this component, just return the HTML // If we're NOT hydrating this component, just return the HTML
if (!componentProps.hydrate) { if (!componentProps.hydrate) {
// It's safe to remove <astro-fragment>, static content doesn't need the wrapper // It's safe to remove <astro-fragment>, static content doesn't need the wrapper
@ -112,7 +161,7 @@ export const __astro_component = (Component: any, componentProps: AstroComponent
// If we ARE hydrating this component, let's generate the hydration script // If we ARE hydrating this component, let's generate the hydration script
const astroId = hash.unique(html); const astroId = hash.unique(html);
const script = await generateHydrateScript({ renderer, astroId, props }, componentProps as Required<AstroComponentProps>); const script = await generateHydrateScript({ instance, astroId, props }, componentProps as Required<AstroComponentProps>);
const astroRoot = `<astro-root uid="${astroId}">${html}</astro-root>`; const astroRoot = `<astro-root uid="${astroId}">${html}</astro-root>`;
return [astroRoot, script].join('\n'); return [astroRoot, script].join('\n');
}; };

View file

@ -0,0 +1,48 @@
import type { AstroComponentProps } from './__astro_component';
type ModuleCandidates = Map<any, string>;
interface RegistryOptions {
candidates: ModuleCandidates;
}
class AstroElementRegistry {
private candidates: ModuleCandidates;
private cache: Map<string, string> = new Map();
constructor(options: RegistryOptions) {
this.candidates = options.candidates;
}
find(tagName: string) {
for(let [module, importSpecifier] of this.candidates) {
if(module && typeof module.tagName === 'string') {
if(module.tagName === tagName) {
// Found!
return importSpecifier;
}
}
}
}
findCached(tagName: string) {
if(this.cache.has(tagName)) {
return this.cache.get(tagName)!;
}
let specifier = this.find(tagName);
if(specifier) {
this.cache.set(tagName, specifier);
}
return specifier;
}
astroComponentArgs(tagName: string, props: AstroComponentProps) {
const specifier = this.findCached(tagName);
const outProps: AstroComponentProps = {
...props,
componentUrl: specifier || props.componentUrl
};
return [tagName, outProps];
}
}
export { AstroElementRegistry };

View file

@ -0,0 +1,12 @@
import { h } from './h';
async function renderToStaticMarkup(tag: string, props: Record<string, any>, children: string) {
const html = await h(tag, props, Promise.resolve(children));
return {
html
};
}
export {
renderToStaticMarkup
};

View file

@ -366,11 +366,20 @@ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackO
const rendererInstances = await configManager.buildRendererInstances(); const rendererInstances = await configManager.buildRendererInstances();
const knownEntrypoints: string[] = ['astro/dist/internal/__astro_component.js']; const knownEntrypoints: string[] = ['astro/dist/internal/__astro_component.js'];
for (const renderer of rendererInstances) { for (const renderer of rendererInstances) {
knownEntrypoints.push(renderer.server, renderer.client); knownEntrypoints.push(renderer.server);
if(renderer.client) {
knownEntrypoints.push(renderer.client);
}
if (renderer.knownEntrypoints) { if (renderer.knownEntrypoints) {
knownEntrypoints.push(...renderer.knownEntrypoints); knownEntrypoints.push(...renderer.knownEntrypoints);
} }
} }
const external = snowpackExternals.concat([]);
for(const renderer of rendererInstances) {
if(renderer.external) {
external.push(...renderer.external);
}
}
const rendererSnowpackPlugins = rendererInstances.filter((renderer) => renderer.snowpackPlugin).map((renderer) => renderer.snowpackPlugin) as string | [string, any]; const rendererSnowpackPlugins = rendererInstances.filter((renderer) => renderer.snowpackPlugin).map((renderer) => renderer.snowpackPlugin) as string | [string, any];
const snowpackConfig = await loadConfiguration({ const snowpackConfig = await loadConfiguration({
@ -406,7 +415,7 @@ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackO
}, },
packageOptions: { packageOptions: {
knownEntrypoints, knownEntrypoints,
external: snowpackExternals, external,
}, },
alias: { alias: {
...Object.fromEntries(nodeBuiltinsMap), ...Object.fromEntries(nodeBuiltinsMap),

View file

@ -0,0 +1,73 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import { doc } from './test-utils.js';
import { setup } from './helpers.js';
const CustomElements = suite('Custom Elements');
setup(CustomElements, './fixtures/custom-elements');
CustomElements('Work as constructors', async ({ runtime }) => {
const result = await runtime.load('/ctr');
if (result.error) throw new Error(result.error);
const $ = doc(result.contents);
assert.equal($('my-element').length, 1, 'Element rendered');
assert.equal($('my-element template[shadowroot=open]').length, 1, 'shadow rendered');
});
CustomElements('Works with exported tagName', async ({ runtime }) => {
const result = await runtime.load('/');
if (result.error) throw new Error(result.error);
const $ = doc(result.contents);
assert.equal($('my-element').length, 1, 'Element rendered');
assert.equal($('my-element template[shadowroot=open]').length, 1, 'shadow rendered');
});
CustomElements('Hydration works with exported tagName', async ({ runtime }) => {
const result = await runtime.load('/load');
if (result.error) throw new Error(result.error);
const html = result.contents;
const $ = doc(html);
// SSR
assert.equal($('my-element').length, 1, 'Element rendered');
assert.equal($('my-element template[shadowroot=open]').length, 1, 'shadow rendered');
// Hydration
assert.ok(new RegExp('/_astro/src/components/my-element.js').test(html), 'Component URL is included');
});
CustomElements('Polyfills are added before the hydration script', async ({ runtime }) => {
const result = await runtime.load('/load');
if (result.error) throw new Error(result.error);
const html = result.contents;
const $ = doc(html);
assert.equal($('script[type=module]').length, 2);
assert.equal($('script[type=module]').attr('src'), '/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js');
});
CustomElements('Polyfills are added even if not hydrating', async ({ runtime }) => {
const result = await runtime.load('/');
if (result.error) throw new Error(result.error);
const html = result.contents;
const $ = doc(html);
assert.equal($('script[type=module]').length, 1);
assert.equal($('script[type=module]').attr('src'), '/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js');
});
CustomElements('Custom elements not claimed by renderer are rendered as regular HTML', async ({ runtime }) => {
const result = await runtime.load('/nossr');
if (result.error) throw new Error(result.error);
const $ = doc(result.contents);
assert.equal($('client-element').length, 1, 'Rendered the client-only element');
});
CustomElements.run();

View file

@ -0,0 +1,6 @@
export default {
renderers: [
'@astrojs/test-custom-element-renderer'
]
}

View file

@ -0,0 +1,8 @@
export default {
name: '@astrojs/test-custom-element-renderer',
server: './server',
polyfills: [
'./polyfill.js'
]
};

View file

@ -0,0 +1,6 @@
{
"name": "@astrojs/test-custom-element-renderer",
"main": "index.js",
"version": "0.0.1",
"type": "module"
}

View file

@ -0,0 +1 @@
console.log('this is a polyfill');

View file

@ -0,0 +1,30 @@
function getConstructor(Component) {
if(typeof Component === 'string') {
const tagName = Component;
Component = customElements.get(tagName);
}
return Component;
}
function check(component) {
const Component = getConstructor(component);
if(typeof Component === 'function' && globalThis.HTMLElement.isPrototypeOf(Component)) {
return true;
}
return false;
}
function renderToStaticMarkup(component) {
const Component = getConstructor(component);
const el = new Component();
el.connectedCallback();
const html = `<${el.localName}><template shadowroot="open">${el.shadowRoot.innerHTML}</template>${el.innerHTML}</${el.localName}>`
return {
html
};
}
export default {
check,
renderToStaticMarkup
};

View file

@ -0,0 +1,7 @@
{
"name": "@astrojs/test-custom-elements",
"version": "0.0.1",
"dependencies": {
"@astrojs/test-custom-element-renderer": "0.0.1"
}
}

View file

@ -0,0 +1,3 @@
{
"workspaceRoot": "../../../../../"
}

View file

@ -0,0 +1,29 @@
globalThis.customElements = {
_elements: new Map(),
define(name, ctr) {
ctr.tagName = name;
this._elements.set(name, ctr);
},
get(name) {
return this._elements.get(name);
}
};
globalThis.HTMLElement = class {
attachShadow() {
this.shadowRoot = new HTMLElement();
}
get localName() {
return this.constructor.tagName;
}
get innerHTML() {
return this._innerHTML;
}
set innerHTML(val) {
this._innerHTML = val;
}
};

View file

@ -0,0 +1,15 @@
import './custom-elements.shim.js';
export const tagName = 'my-element';
class MyElement extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<span id="custom">Hello from a custom element!</span>`;
this.innerHTML = `<div id="custom-light">Light dom!</div>`
}
}
customElements.define(tagName, MyElement);
export default MyElement;

View file

@ -0,0 +1,16 @@
---
import MyElement from '../components/my-element.js';
const title = 'My App';
---
<html>
<head>
<title>{title}</title>
</head>
<body>
<h1>{title}</h1>
<MyElement />
</body>
</html>

View file

@ -0,0 +1,15 @@
---
const title = 'My App';
import '../components/my-element.js';
---
<html>
<head>
<title>{title}</title>
</head>
<body>
<h1>{title}</h1>
<my-element></my-element>
</body>
</html>

View file

@ -0,0 +1,15 @@
---
const title = 'My App';
import '../components/my-element.js';
---
<html>
<head>
<title>{title}</title>
</head>
<body>
<h1>{title}</h1>
<my-element:load></my-element:load>
</body>
</html>

View file

@ -0,0 +1,14 @@
---
const title = 'My App';
---
<html>
<head>
<title>{title}</title>
</head>
<body>
<h1>{title}</h1>
<client-element></client-element>
</body>
</html>

View file

@ -23,7 +23,7 @@ const MAX_SHUTDOWN_TIME = 3000; // max time shutdown() may take
* @param {SetupOptions} setupOptions * @param {SetupOptions} setupOptions
*/ */
export function setup(Suite, fixturePath, { runtimeOptions = {} } = {}) { export function setup(Suite, fixturePath, { runtimeOptions = {} } = {}) {
let runtime; let runtime, createRuntimeError;
const timers = {}; const timers = {};
Suite.before(async (context) => { Suite.before(async (context) => {
@ -36,7 +36,11 @@ export function setup(Suite, fixturePath, { runtimeOptions = {} } = {}) {
runtime = await createRuntime(astroConfig, { runtime = await createRuntime(astroConfig, {
logging: { level: 'error', dest: process.stderr }, logging: { level: 'error', dest: process.stderr },
...runtimeOptions, ...runtimeOptions,
}); }).catch(err => { createRuntimeError = err; });
if(createRuntimeError) {
setTimeout(() => { throw createRuntimeError });
}
context.runtime = runtime; context.runtime = runtime;

3235
yarn.lock

File diff suppressed because it is too large Load diff