[next] Support for custom elements (#1528)
* [next] Support for custom elements * Fix eslint errors * eslint again
This commit is contained in:
parent
dc7260bea2
commit
190fba394d
8 changed files with 147 additions and 64 deletions
37
packages/astro/src/@types/astro-file.ts
Normal file
37
packages/astro/src/@types/astro-file.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
type AstroRenderedHTML = string;
|
||||||
|
|
||||||
|
export type FetchContentResultBase = {
|
||||||
|
astro: {
|
||||||
|
headers: string[];
|
||||||
|
source: string;
|
||||||
|
html: AstroRenderedHTML;
|
||||||
|
};
|
||||||
|
url: URL;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FetchContentResult<T> = FetchContentResultBase & T;
|
||||||
|
|
||||||
|
export type Params = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
interface AstroPageRequest {
|
||||||
|
url: URL;
|
||||||
|
canonicalURL: URL;
|
||||||
|
params: Params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AstroBuiltinProps {
|
||||||
|
'client:load'?: boolean;
|
||||||
|
'client:idle'?: boolean;
|
||||||
|
'client:media'?: string;
|
||||||
|
'client:visible'?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Astro {
|
||||||
|
isPage: boolean;
|
||||||
|
fetchContent<T = any>(globStr: string): Promise<FetchContentResult<T>[]>;
|
||||||
|
props: Record<string, number | string | any>;
|
||||||
|
request: AstroPageRequest;
|
||||||
|
resolve: (path: string) => string;
|
||||||
|
site: URL;
|
||||||
|
slots: Record<string, true | undefined>;
|
||||||
|
}
|
14
packages/astro/src/@types/ssr.ts
Normal file
14
packages/astro/src/@types/ssr.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { Astro as AstroGlobal } from './astro-file';
|
||||||
|
import { Renderer } from './astro';
|
||||||
|
|
||||||
|
export interface SSRMetadata {
|
||||||
|
importedModules: Record<string, any>;
|
||||||
|
renderers: Renderer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSRResult {
|
||||||
|
styles: Set<string>;
|
||||||
|
scripts: Set<string>;
|
||||||
|
createAstro(props: Record<string, any>, slots: Record<string, any> | null): AstroGlobal;
|
||||||
|
_metadata: SSRMetadata;
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import type { AstroComponentMetadata } from '../@types/astro';
|
import type { AstroComponentMetadata } from '../@types/astro';
|
||||||
|
import type { SSRResult } from '../@types/ssr';
|
||||||
|
|
||||||
import { valueToEstree } from 'estree-util-value-to-estree';
|
import { valueToEstree } from 'estree-util-value-to-estree';
|
||||||
import * as astring from 'astring';
|
import * as astring from 'astring';
|
||||||
|
@ -141,7 +142,7 @@ export async function renderSlot(result: any, slotted: string, fallback?: any) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderComponent(result: any, displayName: string, Component: unknown, _props: Record<string | number, any>, slots: any = {}) {
|
export async function renderComponent(result: SSRResult, displayName: string, Component: unknown, _props: Record<string | number, any>, slots: any = {}) {
|
||||||
Component = await Component;
|
Component = await Component;
|
||||||
const children = await renderSlot(result, slots?.default);
|
const children = await renderSlot(result, slots?.default);
|
||||||
const { renderers } = result._metadata;
|
const { renderers } = result._metadata;
|
||||||
|
@ -167,9 +168,16 @@ export async function renderComponent(result: any, displayName: string, Componen
|
||||||
metadata.hydrateArgs = hydrationDirective[1];
|
metadata.hydrateArgs = hydrationDirective[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCustomElement = typeof Component === 'string';
|
||||||
for (const [url, exported] of Object.entries(result._metadata.importedModules)) {
|
for (const [url, exported] of Object.entries(result._metadata.importedModules)) {
|
||||||
for (const [key, value] of Object.entries(exported as any)) {
|
for (const [key, value] of Object.entries(exported as any)) {
|
||||||
if (Component === value) {
|
if(isCustomElement) {
|
||||||
|
if (key === 'tagName' && Component === value) {
|
||||||
|
metadata.componentExport = { value: key };
|
||||||
|
metadata.componentUrl = url;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if(Component === value) {
|
||||||
metadata.componentExport = { value: key };
|
metadata.componentExport = { value: key };
|
||||||
metadata.componentUrl = url;
|
metadata.componentUrl = url;
|
||||||
break;
|
break;
|
||||||
|
@ -194,6 +202,11 @@ export async function renderComponent(result: any, displayName: string, Componen
|
||||||
({ html } = await renderer.ssr.renderToStaticMarkup(Component, props, children));
|
({ html } = await renderer.ssr.renderToStaticMarkup(Component, props, children));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (renderer?.polyfills?.length) {
|
||||||
|
let polyfillScripts = renderer.polyfills.map((src) => `<script type="module">import "${src}";</script>`).join('');
|
||||||
|
html = html + polyfillScripts;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hydrationDirective) {
|
if (!hydrationDirective) {
|
||||||
return html.replace(/\<\/?astro-fragment\>/g, '');
|
return html.replace(/\<\/?astro-fragment\>/g, '');
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import type { BuildResult } from 'esbuild';
|
import type { BuildResult } from 'esbuild';
|
||||||
import type { ViteDevServer } from 'vite';
|
import type { ViteDevServer } from 'vite';
|
||||||
import type { AstroConfig, ComponentInstance, GetStaticPathsResult, Params, Props, RouteCache, RouteData, RuntimeMode, SSRError } from '../@types/astro';
|
import type { AstroConfig, ComponentInstance, GetStaticPathsResult, Params, Props, Renderer, RouteCache, RouteData, RuntimeMode, SSRError } from '../@types/astro';
|
||||||
|
import type { SSRResult } from '../@types/ssr';
|
||||||
|
import type { FetchContentResultBase, FetchContentResult } from '../@types/astro-file';
|
||||||
import type { LogOptions } from '../logger';
|
import type { LogOptions } from '../logger';
|
||||||
|
|
||||||
import cheerio from 'cheerio';
|
import cheerio from 'cheerio';
|
||||||
|
@ -40,32 +42,38 @@ interface SSROptions {
|
||||||
// this prevents client-side errors such as the "double React bug" (https://reactjs.org/warnings/invalid-hook-call-warning.html#mismatching-versions-of-react-and-react-dom)
|
// this prevents client-side errors such as the "double React bug" (https://reactjs.org/warnings/invalid-hook-call-warning.html#mismatching-versions-of-react-and-react-dom)
|
||||||
let browserHash: string | undefined;
|
let browserHash: string | undefined;
|
||||||
|
|
||||||
const cache = new Map();
|
const cache = new Map<string, Promise<Renderer>>();
|
||||||
|
|
||||||
// TODO: improve validation and error handling here.
|
// TODO: improve validation and error handling here.
|
||||||
async function resolveRenderers(viteServer: ViteDevServer, ids: string[]) {
|
async function resolveRenderer(viteServer: ViteDevServer, renderer: string) {
|
||||||
|
const resolvedRenderer: any = {};
|
||||||
|
// We can dynamically import the renderer by itself because it shouldn't have
|
||||||
|
// any non-standard imports, the index is just meta info.
|
||||||
|
// The other entrypoints need to be loaded through Vite.
|
||||||
|
const {
|
||||||
|
default: { name, client, polyfills, hydrationPolyfills, server },
|
||||||
|
} = await import(renderer);
|
||||||
|
|
||||||
|
resolvedRenderer.name = name;
|
||||||
|
if (client) resolvedRenderer.source = path.posix.join(renderer, client);
|
||||||
|
if (Array.isArray(hydrationPolyfills)) resolvedRenderer.hydrationPolyfills = hydrationPolyfills.map((src: string) => path.posix.join(renderer, src));
|
||||||
|
if (Array.isArray(polyfills)) resolvedRenderer.polyfills = polyfills.map((src: string) => path.posix.join(renderer, src));
|
||||||
|
const { url } = await viteServer.moduleGraph.ensureEntryFromUrl(path.posix.join(renderer, server));
|
||||||
|
const { default: rendererSSR } = await viteServer.ssrLoadModule(url);
|
||||||
|
resolvedRenderer.ssr = rendererSSR;
|
||||||
|
|
||||||
|
const completedRenderer: Renderer = resolvedRenderer;
|
||||||
|
return completedRenderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRenderers(viteServer: ViteDevServer, ids: string[]): Promise<Renderer[]> {
|
||||||
const renderers = await Promise.all(
|
const renderers = await Promise.all(
|
||||||
ids.map(async (renderer) => {
|
ids.map(renderer => {
|
||||||
if (cache.has(renderer)) return cache.get(renderer);
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
if (cache.has(renderer)) return cache.get(renderer)!;
|
||||||
const resolvedRenderer: any = {};
|
let promise = resolveRenderer(viteServer, renderer);
|
||||||
// We can dynamically import the renderer by itself because it shouldn't have
|
cache.set(renderer, promise);
|
||||||
// any non-standard imports, the index is just meta info.
|
return promise;
|
||||||
// The other entrypoints need to be loaded through Vite.
|
|
||||||
const {
|
|
||||||
default: { name, client, polyfills, hydrationPolyfills, server },
|
|
||||||
} = await import(renderer);
|
|
||||||
|
|
||||||
resolvedRenderer.name = name;
|
|
||||||
if (client) resolvedRenderer.source = path.posix.join(renderer, client);
|
|
||||||
if (Array.isArray(hydrationPolyfills)) resolvedRenderer.hydrationPolyfills = hydrationPolyfills.map((src: string) => path.posix.join(renderer, src));
|
|
||||||
if (Array.isArray(polyfills)) resolvedRenderer.polyfills = polyfills.map((src: string) => path.posix.join(renderer, src));
|
|
||||||
const { url } = await viteServer.moduleGraph.ensureEntryFromUrl(path.posix.join(renderer, server));
|
|
||||||
const { default: rendererSSR } = await viteServer.ssrLoadModule(url);
|
|
||||||
resolvedRenderer.ssr = rendererSSR;
|
|
||||||
|
|
||||||
cache.set(renderer, resolvedRenderer);
|
|
||||||
return resolvedRenderer;
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -118,20 +126,26 @@ async function resolveImportedModules(viteServer: ViteDevServer, file: URL) {
|
||||||
/** use Vite to SSR */
|
/** use Vite to SSR */
|
||||||
export async function ssr({ astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer }: SSROptions): Promise<string> {
|
export async function ssr({ astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer }: SSROptions): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// 1. load module
|
// 1. resolve renderers
|
||||||
|
// Important this happens before load module in case a renderer provides polyfills.
|
||||||
|
const renderers = await resolveRenderers(viteServer, astroConfig.renderers);
|
||||||
|
|
||||||
|
// 1.5. load module
|
||||||
const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance;
|
const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance;
|
||||||
|
|
||||||
// 1.5. resolve renderers and imported modules.
|
// 1.75. resolve renderers
|
||||||
// important that this happens _after_ ssrLoadModule, otherwise `importedModules` would be empty
|
// important that this happens _after_ ssrLoadModule, otherwise `importedModules` would be empty
|
||||||
const [renderers, importedModules] = await Promise.all([resolveRenderers(viteServer, astroConfig.renderers), resolveImportedModules(viteServer, filePath)]);
|
const importedModules = await resolveImportedModules(viteServer, filePath);
|
||||||
|
|
||||||
// 2. handle dynamic routes
|
// 2. handle dynamic routes
|
||||||
let params: Params = {};
|
let params: Params = {};
|
||||||
let pageProps: Props = {};
|
let pageProps: Props = {};
|
||||||
if (route && !route.pathname) {
|
if (route && !route.pathname) {
|
||||||
if (route.params.length) {
|
if (route.params.length) {
|
||||||
const paramsMatch = route.pattern.exec(pathname)!;
|
const paramsMatch = route.pattern.exec(pathname);
|
||||||
params = getParams(route.params)(paramsMatch);
|
if(paramsMatch) {
|
||||||
|
params = getParams(route.params)(paramsMatch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
validateGetStaticPathsModule(mod);
|
validateGetStaticPathsModule(mod);
|
||||||
routeCache[route.component] =
|
routeCache[route.component] =
|
||||||
|
@ -163,11 +177,11 @@ export async function ssr({ astroConfig, filePath, logging, mode, origin, pathna
|
||||||
|
|
||||||
if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`);
|
if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`);
|
||||||
|
|
||||||
const result = {
|
const result: SSRResult = {
|
||||||
styles: new Set(),
|
styles: new Set(),
|
||||||
scripts: new Set(),
|
scripts: new Set(),
|
||||||
/** This function returns the `Astro` faux-global */
|
/** This function returns the `Astro` faux-global */
|
||||||
createAstro: (props: any, slots: Record<string, any> | null) => {
|
createAstro: (props: Record<string, any>, slots: Record<string, any> | null) => {
|
||||||
const site = new URL(origin);
|
const site = new URL(origin);
|
||||||
const url = new URL('.' + pathname, site);
|
const url = new URL('.' + pathname, site);
|
||||||
const canonicalURL = getCanonicalURL(pathname, astroConfig.buildOptions.site || origin);
|
const canonicalURL = getCanonicalURL(pathname, astroConfig.buildOptions.site || origin);
|
||||||
|
@ -175,30 +189,46 @@ export async function ssr({ astroConfig, filePath, logging, mode, origin, pathna
|
||||||
return {
|
return {
|
||||||
isPage: true,
|
isPage: true,
|
||||||
site,
|
site,
|
||||||
request: { url, canonicalURL },
|
request: {
|
||||||
|
canonicalURL,
|
||||||
|
params: {},
|
||||||
|
url
|
||||||
|
},
|
||||||
props,
|
props,
|
||||||
fetchContent,
|
fetchContent,
|
||||||
slots: Object.fromEntries(
|
slots: Object.fromEntries(
|
||||||
Object.entries(slots || {}).map(([slotName]) => [slotName, true])
|
Object.entries(slots || {}).map(([slotName]) => [slotName, true])
|
||||||
)
|
),
|
||||||
|
// Only temporary to get types working.
|
||||||
|
resolve(_s: string) {
|
||||||
|
throw new Error('Astro.resolve() is not currently supported in next.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
_metadata: { importedModules, renderers },
|
_metadata: { importedModules, renderers },
|
||||||
};
|
};
|
||||||
|
|
||||||
const createFetchContent = (currentFilePath: string) => {
|
function createFetchContent(currentFilePath: string) {
|
||||||
const fetchContentCache = new Map<string, any>();
|
const fetchContentCache = new Map<string, any>();
|
||||||
return async (pattern: string) => {
|
return async function fetchContent<T>(pattern: string): Promise<FetchContentResult<T>[]> {
|
||||||
const cwd = path.dirname(currentFilePath);
|
const cwd = path.dirname(currentFilePath);
|
||||||
const cacheKey = `${cwd}:${pattern}`;
|
const cacheKey = `${cwd}:${pattern}`;
|
||||||
if (fetchContentCache.has(cacheKey)) {
|
if (fetchContentCache.has(cacheKey)) {
|
||||||
return fetchContentCache.get(cacheKey);
|
return fetchContentCache.get(cacheKey);
|
||||||
}
|
}
|
||||||
const files = await glob(pattern, { cwd, absolute: true });
|
const files = await glob(pattern, { cwd, absolute: true });
|
||||||
const contents = await Promise.all(
|
const contents: FetchContentResult<T>[] = await Promise.all(
|
||||||
files.map(async (file) => {
|
files.map(async (file) => {
|
||||||
const { metadata: astro = {}, frontmatter = {} } = (await viteServer.ssrLoadModule(file)) as any;
|
const loadedModule = await viteServer.ssrLoadModule(file);
|
||||||
return { ...frontmatter, astro };
|
const astro = (loadedModule.metadata || {}) as FetchContentResultBase['astro'];
|
||||||
|
const frontmatter = loadedModule.frontmatter || {};
|
||||||
|
//eslint-disable-next-line no-shadow
|
||||||
|
const result: FetchContentResult<T> = {
|
||||||
|
...frontmatter,
|
||||||
|
astro,
|
||||||
|
url: new URL('http://example.com') // TODO fix
|
||||||
|
};
|
||||||
|
return result;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
fetchContentCache.set(cacheKey, contents);
|
fetchContentCache.set(cacheKey, contents);
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
/**
|
|
||||||
* UNCOMMENT: add support for custom elements
|
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
import cheerio from 'cheerio';
|
import cheerio from 'cheerio';
|
||||||
import { loadFixture } from './test-utils.js';
|
import { loadFixture } from './test-utils.js';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
let fixture;
|
let fixture;
|
||||||
|
|
||||||
|
@ -49,17 +48,8 @@ describe('Custom Elements', () => {
|
||||||
expect($('my-element template[shadowroot=open]')).to.have.lengthOf(1);
|
expect($('my-element template[shadowroot=open]')).to.have.lengthOf(1);
|
||||||
|
|
||||||
// Hydration
|
// Hydration
|
||||||
// test 3: Component URL is included
|
// test 3: Component and polyfill scripts included
|
||||||
expect(html).to.include('/src/components/my-element.js');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Polyfills are added before the hydration script', async () => {
|
|
||||||
const html = await fixture.readFile('/load/index.html');
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
|
|
||||||
expect($('script[type=module]')).to.have.lengthOf(2);
|
expect($('script[type=module]')).to.have.lengthOf(2);
|
||||||
expect($('script[type=module]').attr('src')).to.equal('/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js');
|
|
||||||
expect($($('script[type=module]').get(1)).html()).to.include('/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/hydration-polyfill.js');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Polyfills are added even if not hydrating', async () => {
|
it('Polyfills are added even if not hydrating', async () => {
|
||||||
|
@ -67,10 +57,6 @@ describe('Custom Elements', () => {
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
expect($('script[type=module]')).to.have.lengthOf(1);
|
expect($('script[type=module]')).to.have.lengthOf(1);
|
||||||
expect($('script[type=module]').attr('src')).to.equal('/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js');
|
|
||||||
expect($($('script[type=module]').get(1)).html()).not.to.include(
|
|
||||||
'/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/hydration-polyfill.js'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Custom elements not claimed by renderer are rendered as regular HTML', async () => {
|
it('Custom elements not claimed by renderer are rendered as regular HTML', async () => {
|
||||||
|
@ -88,7 +74,4 @@ describe('Custom Elements', () => {
|
||||||
// test 1: Element rendered
|
// test 1: Element rendered
|
||||||
expect($('client-only-element')).to.have.lengthOf(1);
|
expect($('client-only-element')).to.have.lengthOf(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
*/
|
|
||||||
|
|
||||||
it.skip('is skipped', () => {});
|
|
|
@ -1,7 +1,6 @@
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: '@astrojs/test-custom-element-renderer',
|
name: '@astrojs/test-custom-element-renderer',
|
||||||
server: './server',
|
server: './server.js',
|
||||||
polyfills: [
|
polyfills: [
|
||||||
'./polyfill.js'
|
'./polyfill.js'
|
||||||
],
|
],
|
||||||
|
|
|
@ -3,5 +3,11 @@
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module"
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.js",
|
||||||
|
"./server.js": "./server.js",
|
||||||
|
"./polyfill.js": "./polyfill.js",
|
||||||
|
"./hydration-polyfill.js": "./hydration-polyfill.js"
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1 +1,2 @@
|
||||||
console.log('this is a polyfill');
|
console.log('this is a polyfill');
|
||||||
|
export default {};
|
Loading…
Add table
Reference in a new issue