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:
parent
81ea010906
commit
6a660f1b08
34 changed files with 2142 additions and 1736 deletions
6
.changeset/early-terms-bow.md
Normal file
6
.changeset/early-terms-bow.md
Normal 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.
|
|
@ -100,10 +100,12 @@ Additionally, this entrypoint can define a [Snowpack plugin](https://www.snowpac
|
|||
export default {
|
||||
name: '@astrojs/renderer-xxx', // the renderer name
|
||||
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
|
||||
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
|
||||
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.
|
||||
};
|
||||
```
|
||||
|
||||
|
|
|
@ -29,7 +29,8 @@
|
|||
"scripts",
|
||||
"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": {
|
||||
"node": "14.16.1",
|
||||
|
@ -37,7 +38,8 @@
|
|||
"yarn": "1.22.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@changesets/cli": "^2.16.0"
|
||||
"@changesets/cli": "^2.16.0",
|
||||
"camel-case": "^4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^4.22.0",
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
export * from './interfaces';
|
||||
export * from './parse/utils/features';
|
||||
export { default as parse } from './parse/index.js';
|
||||
|
|
|
@ -106,6 +106,9 @@ export interface Ast {
|
|||
css: Style;
|
||||
module: Script;
|
||||
// instance: Script;
|
||||
meta: {
|
||||
features: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Warning {
|
||||
|
|
|
@ -29,6 +29,7 @@ export class Parser {
|
|||
js: Script[] = [];
|
||||
meta_tags = {};
|
||||
last_auto_closed_tag?: LastAutoClosedTag;
|
||||
feature_flags: 0
|
||||
|
||||
constructor(template: string, options: ParserOptions) {
|
||||
if (typeof template !== 'string') {
|
||||
|
@ -266,5 +267,8 @@ export default function parse(template: string, options: ParserOptions = {}): As
|
|||
css: parser.css[0],
|
||||
// instance: instance_scripts[0],
|
||||
module: astro_scripts[0],
|
||||
meta: {
|
||||
features: parser.feature_flags
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { Parser } from '../index.js';
|
|||
import { Directive, DirectiveType, TemplateNode, Text } from '../../interfaces.js';
|
||||
import fuzzymatch from '../../utils/fuzzymatch.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\-]*/;
|
||||
|
||||
|
@ -43,6 +44,7 @@ const SELF = /^astro:self(?=[\s/>])/;
|
|||
const COMPONENT = /^astro:component(?=[\s/>])/;
|
||||
const SLOT = /^astro:fragment(?=[\s/>])/;
|
||||
const HEAD = /^head(?=[\s/>])/;
|
||||
const CUSTOM_ELEMENT = /-/;
|
||||
|
||||
function parent_is_head(stack) {
|
||||
let i = stack.length;
|
||||
|
@ -54,6 +56,7 @@ function parent_is_head(stack) {
|
|||
return false;
|
||||
}
|
||||
|
||||
|
||||
export default function tag(parser: Parser) {
|
||||
const start = parser.index++;
|
||||
|
||||
|
@ -77,6 +80,10 @@ export default function tag(parser: Parser) {
|
|||
|
||||
const name = read_tag_name(parser);
|
||||
|
||||
if(CUSTOM_ELEMENT.test(name)) {
|
||||
parser.feature_flags |= FEATURE_CUSTOM_ELEMENT;
|
||||
}
|
||||
|
||||
if (meta_tags.has(name)) {
|
||||
const slug = meta_tags.get(name).toLowerCase();
|
||||
if (is_closing_tag) {
|
||||
|
|
2
packages/astro-parser/src/parse/utils/features.ts
Normal file
2
packages/astro-parser/src/parse/utils/features.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
|
||||
export const FEATURE_CUSTOM_ELEMENT = 1 << 0;
|
|
@ -65,6 +65,8 @@ export interface TransformResult {
|
|||
css?: string;
|
||||
/** If this page exports a collection, the JS to be executed as a string */
|
||||
createCollection?: string;
|
||||
hasCustomElements: boolean;
|
||||
customElementCandidates: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface CompileResult {
|
||||
|
@ -180,7 +182,7 @@ export interface 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 {
|
||||
check: AsyncRendererComponentFn<boolean>;
|
||||
|
|
|
@ -7,7 +7,7 @@ import 'source-map-support/register.js';
|
|||
import eslexer from 'es-module-lexer';
|
||||
import esbuild from 'esbuild';
|
||||
import path from 'path';
|
||||
import { parse } from '@astrojs/parser';
|
||||
import { parse, FEATURE_CUSTOM_ELEMENT } from '@astrojs/parser';
|
||||
import { walk, asyncWalk } from 'estree-walker';
|
||||
import _babelGenerator from '@babel/generator';
|
||||
import babelParser from '@babel/parser';
|
||||
|
@ -17,12 +17,14 @@ import { error, warn, parseError } from '../../logger.js';
|
|||
import { fetchContent } from './content.js';
|
||||
import { isFetchContent } from './utils.js';
|
||||
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 { camelCase } from 'camel-case';
|
||||
import { transform } from '../transform/index.js';
|
||||
import { PRISM_IMPORT } from '../transform/prism.js';
|
||||
import { nodeBuiltinsSet } from '../../node_builtins.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
const traverse: typeof babelTraverse.default = (babelTraverse.default as any).default;
|
||||
|
||||
|
@ -142,6 +144,13 @@ function generateAttributes(attrs: Record<string, string>): string {
|
|||
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 {
|
||||
filename: string;
|
||||
astroConfig: AstroConfig;
|
||||
|
@ -151,14 +160,16 @@ const PlainExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
|||
/** Generate Astro-friendly component import */
|
||||
function getComponentWrapper(_name: string, { url, importSpecifier }: ComponentInfo, opts: GetComponentWrapperOptions) {
|
||||
const { astroConfig, filename } = opts;
|
||||
const currFileUrl = new URL(`file://${filename}`);
|
||||
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);
|
||||
|
||||
// Special flow for custom elements
|
||||
if (isCustomElementTag(name)) {
|
||||
return {
|
||||
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':
|
||||
|
@ -176,11 +187,16 @@ function getComponentWrapper(_name: string, { url, importSpecifier }: ComponentI
|
|||
}
|
||||
};
|
||||
|
||||
const importInfo = kind ? { componentUrl: getComponentUrl(), componentExport: getComponentExport() } : {};
|
||||
const importInfo = kind ? {
|
||||
componentUrl: getComponentUrl(astroConfig, url, pathToFileURL(filename)),
|
||||
componentExport: getComponentExport()
|
||||
} : {};
|
||||
|
||||
return {
|
||||
wrapper: `__astro_component(${name}, ${JSON.stringify({ hydrate: kind, displayName: _name, ...importInfo })})`,
|
||||
wrapperImport: `import {__astro_component} from 'astro/dist/internal/__astro_component.js';`,
|
||||
wrapperImports: [`import {__astro_component} from 'astro/dist/internal/__astro_component.js';`],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -251,19 +267,22 @@ interface CompileResult {
|
|||
}
|
||||
|
||||
interface CodegenState {
|
||||
filename: string;
|
||||
fileID: string;
|
||||
components: Components;
|
||||
css: string[];
|
||||
filename: string;
|
||||
fileID: string;
|
||||
markers: {
|
||||
insideMarkdown: boolean | Record<string, any>;
|
||||
};
|
||||
exportStatements: Set<string>;
|
||||
importStatements: Set<string>;
|
||||
customElementCandidates: Map<string, string>;
|
||||
}
|
||||
|
||||
/** 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 componentProps: VariableDeclarator[] = [];
|
||||
const componentExports: ExportNamedDeclaration[] = [];
|
||||
|
@ -373,8 +392,15 @@ function compileModule(module: Script, state: CodegenState, compileOptions: Comp
|
|||
});
|
||||
}
|
||||
const { start, end } = componentImport;
|
||||
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
|
||||
for (const componentImport of componentExports) {
|
||||
|
@ -385,7 +411,6 @@ function compileModule(module: Script, state: CodegenState, compileOptions: Comp
|
|||
if (componentProps.length > 0) {
|
||||
const shortname = path.posix.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename);
|
||||
const props = componentProps.map((prop) => (prop.id as Identifier)?.name).filter((v) => v);
|
||||
console.log();
|
||||
warn(
|
||||
compileOptions.logging,
|
||||
shortname,
|
||||
|
@ -627,7 +652,7 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
|
|||
const [componentNamespace] = componentName.split('.');
|
||||
componentInfo = components.get(componentNamespace);
|
||||
}
|
||||
if (!componentInfo) {
|
||||
if (!componentInfo && !isCustomElementTag(componentName)) {
|
||||
throw new Error(`Unknown Component: ${componentName}`);
|
||||
}
|
||||
if (componentName === 'Markdown') {
|
||||
|
@ -643,10 +668,12 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
|
|||
curr = 'markdown';
|
||||
return;
|
||||
}
|
||||
const { wrapper, wrapperImport } = getComponentWrapper(name, componentInfo, { astroConfig, filename });
|
||||
if (wrapperImport) {
|
||||
const { wrapper, wrapperImports } = getComponentWrapper(name, componentInfo ?? ({} as any), { astroConfig, filename });
|
||||
if (wrapperImports) {
|
||||
for(let wrapperImport of wrapperImports) {
|
||||
importStatements.add(wrapperImport);
|
||||
}
|
||||
}
|
||||
if (curr === 'markdown') {
|
||||
await pushMarkdownToBuffer();
|
||||
}
|
||||
|
@ -794,9 +821,10 @@ export async function codegen(ast: Ast, { compileOptions, filename, fileID }: Co
|
|||
},
|
||||
importStatements: 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);
|
||||
|
||||
|
@ -809,5 +837,7 @@ export async function codegen(ast: Ast, { compileOptions, filename, fileID }: Co
|
|||
html,
|
||||
css: state.css.length ? state.css.join('\n\n') : undefined,
|
||||
createCollection,
|
||||
hasCustomElements: Boolean(ast.meta.features & FEATURE_CUSTOM_ELEMENT),
|
||||
customElementCandidates: state.customElementCandidates
|
||||
};
|
||||
}
|
||||
|
|
|
@ -106,7 +106,7 @@ interface CompileComponentOptions {
|
|||
isPage?: boolean;
|
||||
}
|
||||
/** 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 site = compileOptions.astroConfig.buildOptions.site || `http://localhost:${compileOptions.astroConfig.devOptions.port}`;
|
||||
|
||||
|
@ -116,6 +116,11 @@ import fetch from 'node-fetch';
|
|||
|
||||
// <script astro></script>
|
||||
${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.
|
||||
import { h, Fragment } from 'astro/dist/internal/h.js';
|
||||
|
|
|
@ -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 */
|
||||
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 {
|
||||
|
|
|
@ -9,10 +9,13 @@ type RendererSnowpackPlugin = string | [string, any] | undefined;
|
|||
|
||||
interface RendererInstance {
|
||||
name: string;
|
||||
options: any;
|
||||
snowpackPlugin: RendererSnowpackPlugin;
|
||||
client: string;
|
||||
client: string | null;
|
||||
server: string;
|
||||
knownEntrypoints: string[] | undefined;
|
||||
external: string[] | undefined;
|
||||
polyfills: string[];
|
||||
}
|
||||
|
||||
const CONFIG_MODULE_BASE_NAME = '__astro_config.js';
|
||||
|
@ -65,15 +68,25 @@ export class ConfigManager {
|
|||
|
||||
const rendererInstances = (
|
||||
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();
|
||||
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;
|
||||
|
||||
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!`);
|
||||
}
|
||||
|
||||
|
@ -92,12 +105,17 @@ export class ConfigManager {
|
|||
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 {
|
||||
name,
|
||||
options,
|
||||
snowpackPlugin,
|
||||
client: path.join(name, raw.client),
|
||||
client: raw.client ? path.join(name, raw.client) : null,
|
||||
server: path.join(name, raw.server),
|
||||
knownEntrypoints: raw.knownEntrypoints,
|
||||
external: raw.external,
|
||||
polyfills: polyfillsNormalized
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -107,16 +125,24 @@ export class ConfigManager {
|
|||
async buildSource(contents: string): Promise<string> {
|
||||
const renderers = await this.buildRendererInstances();
|
||||
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')}
|
||||
|
||||
import { setRenderers } from 'astro/dist/internal/__astro_component.js';
|
||||
|
||||
let rendererSources = [${rendererClientPackages.map((pkg) => `"${pkg}"`).join(', ')}];
|
||||
let renderers = [${rendererServerPackages.map((_, i) => `__renderer_${i}`).join(', ')}];
|
||||
let rendererInstances = [${renderers.map((r, i) => `{
|
||||
source: ${rendererClientPackages[i] ? `"${rendererClientPackages[i]}"` : 'null'},
|
||||
renderer: __renderer_${i},
|
||||
options: ${r.options ? JSON.stringify(r.options) : 'null'},
|
||||
polyfills: ${JSON.stringify(rendererPolyfills[i])}
|
||||
}`).join(', ')}];
|
||||
|
||||
${contents}
|
||||
`;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
declare function setRenderers(sources: string[], renderers: any[]): void;
|
||||
import type { RendererInstance } from '../internal/__astro_component';
|
||||
|
||||
declare let rendererSources: string[];
|
||||
declare let renderers: any[];
|
||||
declare function setRenderers(instances: RendererInstance[]): void;
|
||||
declare let rendererInstances: RendererInstance[];
|
||||
|
||||
setRenderers(rendererSources, renderers);
|
||||
setRenderers(rendererInstances);
|
||||
|
|
|
@ -3,38 +3,64 @@ import hash from 'shorthash';
|
|||
import { valueToEstree, Value } from 'estree-util-value-to-estree';
|
||||
import { generate } from 'astring';
|
||||
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
|
||||
// see https://github.com/remcohaszing/estree-util-value-to-estree#readme
|
||||
const serialize = (value: Value) => generate(valueToEstree(value));
|
||||
|
||||
let rendererSources: string[] = [];
|
||||
let renderers: Renderer[] = [];
|
||||
|
||||
export function setRenderers(_rendererSources: string[], _renderers: Renderer[]) {
|
||||
rendererSources = [''].concat(_rendererSources);
|
||||
renderers = [astro as Renderer].concat(_renderers);
|
||||
export interface RendererInstance {
|
||||
source: string | null;
|
||||
renderer: Renderer;
|
||||
options: any;
|
||||
polyfills: string[];
|
||||
}
|
||||
|
||||
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 */
|
||||
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)) {
|
||||
return rendererCache.get(Component);
|
||||
return rendererCache.get(Component)!;
|
||||
}
|
||||
|
||||
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!
|
||||
// __renderer.check can't be run in parallel, it
|
||||
// returns the first match and skips any subsequent checks
|
||||
try {
|
||||
const shouldUse: boolean = await __renderer.check(Component, props, children);
|
||||
const shouldUse: boolean = await renderer.check(Component, props, children, options);
|
||||
|
||||
if (shouldUse) {
|
||||
rendererCache.set(Component, __renderer);
|
||||
return __renderer;
|
||||
rendererCache.set(Component, instance);
|
||||
return instance;
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(err);
|
||||
|
@ -47,26 +73,39 @@ async function resolveRenderer(Component: any, props: any = {}, children?: strin
|
|||
}
|
||||
}
|
||||
|
||||
interface AstroComponentProps {
|
||||
export interface AstroComponentProps {
|
||||
displayName: string;
|
||||
hydrate?: 'load' | 'idle' | 'visible';
|
||||
componentUrl?: string;
|
||||
componentExport?: { value: string; namespace?: boolean };
|
||||
}
|
||||
|
||||
/** For hydrated components, generate a <script type="module"> to load the component */
|
||||
async function generateHydrateScript({ renderer, astroId, props }: any, { hydrate, componentUrl, componentExport }: Required<AstroComponentProps>) {
|
||||
const rendererSource = rendererSources[renderers.findIndex((r) => r === renderer)];
|
||||
interface HydrateScriptOptions {
|
||||
instance: RendererInstance;
|
||||
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';
|
||||
setup("${astroId}", async () => {
|
||||
const [{ ${componentExport.value}: Component }, { default: hydrate }] = await Promise.all([import("${componentUrl}"), import("${rendererSource}")]);
|
||||
return (el, children) => hydrate(el)(Component, ${serialize(props)}, children);
|
||||
${hydrationSource}
|
||||
});
|
||||
</script>`;
|
||||
|
||||
return script;
|
||||
return hydrationScript;
|
||||
}
|
||||
|
||||
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) => {
|
||||
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?`);
|
||||
} 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?`);
|
||||
}
|
||||
|
||||
return async (props: any, ..._children: string[]) => {
|
||||
const children = _children.join('\n');
|
||||
let renderer = await resolveRenderer(Component, props, children);
|
||||
let instance = await resolveRenderer(Component, props, children);
|
||||
|
||||
if (!renderer) {
|
||||
if (!instance) {
|
||||
if(isCustomElementTag(Component)) {
|
||||
instance = astroHtmlRendererInstance;
|
||||
} else {
|
||||
// If the user only specifies a single renderer, but the check failed
|
||||
// for some reason... just default to their preferred renderer.
|
||||
renderer = rendererSources.length === 2 ? renderers[1] : null;
|
||||
instance = rendererInstances.length === 2 ? rendererInstances[1] : undefined;
|
||||
}
|
||||
|
||||
if (!renderer) {
|
||||
if (!instance) {
|
||||
const name = getComponentName(Component, componentProps);
|
||||
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 (!componentProps.hydrate) {
|
||||
// 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
|
||||
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>`;
|
||||
return [astroRoot, script].join('\n');
|
||||
};
|
||||
|
|
48
packages/astro/src/internal/element-registry.ts
Normal file
48
packages/astro/src/internal/element-registry.ts
Normal 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 };
|
12
packages/astro/src/internal/renderer-html.ts
Normal file
12
packages/astro/src/internal/renderer-html.ts
Normal 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
|
||||
};
|
|
@ -366,11 +366,20 @@ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackO
|
|||
const rendererInstances = await configManager.buildRendererInstances();
|
||||
const knownEntrypoints: string[] = ['astro/dist/internal/__astro_component.js'];
|
||||
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) {
|
||||
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 snowpackConfig = await loadConfiguration({
|
||||
|
@ -406,7 +415,7 @@ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackO
|
|||
},
|
||||
packageOptions: {
|
||||
knownEntrypoints,
|
||||
external: snowpackExternals,
|
||||
external,
|
||||
},
|
||||
alias: {
|
||||
...Object.fromEntries(nodeBuiltinsMap),
|
||||
|
|
73
packages/astro/test/custom-elements.test.js
Normal file
73
packages/astro/test/custom-elements.test.js
Normal 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();
|
6
packages/astro/test/fixtures/custom-elements/astro.config.mjs
vendored
Normal file
6
packages/astro/test/fixtures/custom-elements/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
export default {
|
||||
renderers: [
|
||||
'@astrojs/test-custom-element-renderer'
|
||||
]
|
||||
}
|
8
packages/astro/test/fixtures/custom-elements/my-component-lib/index.js
vendored
Normal file
8
packages/astro/test/fixtures/custom-elements/my-component-lib/index.js
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
|
||||
export default {
|
||||
name: '@astrojs/test-custom-element-renderer',
|
||||
server: './server',
|
||||
polyfills: [
|
||||
'./polyfill.js'
|
||||
]
|
||||
};
|
6
packages/astro/test/fixtures/custom-elements/my-component-lib/package.json
vendored
Normal file
6
packages/astro/test/fixtures/custom-elements/my-component-lib/package.json
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@astrojs/test-custom-element-renderer",
|
||||
"main": "index.js",
|
||||
"version": "0.0.1",
|
||||
"type": "module"
|
||||
}
|
1
packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js
vendored
Normal file
1
packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
console.log('this is a polyfill');
|
30
packages/astro/test/fixtures/custom-elements/my-component-lib/server.js
vendored
Normal file
30
packages/astro/test/fixtures/custom-elements/my-component-lib/server.js
vendored
Normal 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
|
||||
};
|
7
packages/astro/test/fixtures/custom-elements/package.json
vendored
Normal file
7
packages/astro/test/fixtures/custom-elements/package.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "@astrojs/test-custom-elements",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@astrojs/test-custom-element-renderer": "0.0.1"
|
||||
}
|
||||
}
|
3
packages/astro/test/fixtures/custom-elements/snowpack.config.json
vendored
Normal file
3
packages/astro/test/fixtures/custom-elements/snowpack.config.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"workspaceRoot": "../../../../../"
|
||||
}
|
29
packages/astro/test/fixtures/custom-elements/src/components/custom-elements.shim.js
vendored
Normal file
29
packages/astro/test/fixtures/custom-elements/src/components/custom-elements.shim.js
vendored
Normal 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;
|
||||
}
|
||||
};
|
15
packages/astro/test/fixtures/custom-elements/src/components/my-element.js
vendored
Normal file
15
packages/astro/test/fixtures/custom-elements/src/components/my-element.js
vendored
Normal 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;
|
16
packages/astro/test/fixtures/custom-elements/src/pages/ctr.astro
vendored
Normal file
16
packages/astro/test/fixtures/custom-elements/src/pages/ctr.astro
vendored
Normal 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>
|
15
packages/astro/test/fixtures/custom-elements/src/pages/index.astro
vendored
Normal file
15
packages/astro/test/fixtures/custom-elements/src/pages/index.astro
vendored
Normal 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>
|
15
packages/astro/test/fixtures/custom-elements/src/pages/load.astro
vendored
Normal file
15
packages/astro/test/fixtures/custom-elements/src/pages/load.astro
vendored
Normal 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>
|
14
packages/astro/test/fixtures/custom-elements/src/pages/nossr.astro
vendored
Normal file
14
packages/astro/test/fixtures/custom-elements/src/pages/nossr.astro
vendored
Normal 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>
|
|
@ -23,7 +23,7 @@ const MAX_SHUTDOWN_TIME = 3000; // max time shutdown() may take
|
|||
* @param {SetupOptions} setupOptions
|
||||
*/
|
||||
export function setup(Suite, fixturePath, { runtimeOptions = {} } = {}) {
|
||||
let runtime;
|
||||
let runtime, createRuntimeError;
|
||||
const timers = {};
|
||||
|
||||
Suite.before(async (context) => {
|
||||
|
@ -36,7 +36,11 @@ export function setup(Suite, fixturePath, { runtimeOptions = {} } = {}) {
|
|||
runtime = await createRuntime(astroConfig, {
|
||||
logging: { level: 'error', dest: process.stderr },
|
||||
...runtimeOptions,
|
||||
});
|
||||
}).catch(err => { createRuntimeError = err; });
|
||||
|
||||
if(createRuntimeError) {
|
||||
setTimeout(() => { throw createRuntimeError });
|
||||
}
|
||||
|
||||
context.runtime = runtime;
|
||||
|
||||
|
|
Loading…
Reference in a new issue