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 {
|
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.
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
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;
|
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,7 +182,7 @@ 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>;
|
||||||
|
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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');
|
||||||
};
|
};
|
||||||
|
|
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 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),
|
||||||
|
|
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
|
* @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;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue