[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 { SSRResult } from '../@types/ssr';
|
||||
|
||||
import { valueToEstree } from 'estree-util-value-to-estree';
|
||||
import * as astring from 'astring';
|
||||
|
@ -141,7 +142,7 @@ export async function renderSlot(result: any, slotted: string, fallback?: any) {
|
|||
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;
|
||||
const children = await renderSlot(result, slots?.default);
|
||||
const { renderers } = result._metadata;
|
||||
|
@ -167,9 +168,16 @@ export async function renderComponent(result: any, displayName: string, Componen
|
|||
metadata.hydrateArgs = hydrationDirective[1];
|
||||
}
|
||||
|
||||
const isCustomElement = typeof Component === 'string';
|
||||
for (const [url, exported] of Object.entries(result._metadata.importedModules)) {
|
||||
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.componentUrl = url;
|
||||
break;
|
||||
|
@ -194,6 +202,11 @@ export async function renderComponent(result: any, displayName: string, Componen
|
|||
({ 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) {
|
||||
return html.replace(/\<\/?astro-fragment\>/g, '');
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import type { BuildResult } from 'esbuild';
|
||||
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 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)
|
||||
let browserHash: string | undefined;
|
||||
|
||||
const cache = new Map();
|
||||
const cache = new Map<string, Promise<Renderer>>();
|
||||
|
||||
// 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(
|
||||
ids.map(async (renderer) => {
|
||||
if (cache.has(renderer)) return cache.get(renderer);
|
||||
|
||||
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;
|
||||
|
||||
cache.set(renderer, resolvedRenderer);
|
||||
return resolvedRenderer;
|
||||
ids.map(renderer => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (cache.has(renderer)) return cache.get(renderer)!;
|
||||
let promise = resolveRenderer(viteServer, renderer);
|
||||
cache.set(renderer, promise);
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
|
||||
|
@ -118,20 +126,26 @@ async function resolveImportedModules(viteServer: ViteDevServer, file: URL) {
|
|||
/** use Vite to SSR */
|
||||
export async function ssr({ astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer }: SSROptions): Promise<string> {
|
||||
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;
|
||||
|
||||
// 1.5. resolve renderers and imported modules.
|
||||
// 1.75. resolve renderers
|
||||
// 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
|
||||
let params: Params = {};
|
||||
let pageProps: Props = {};
|
||||
if (route && !route.pathname) {
|
||||
if (route.params.length) {
|
||||
const paramsMatch = route.pattern.exec(pathname)!;
|
||||
params = getParams(route.params)(paramsMatch);
|
||||
const paramsMatch = route.pattern.exec(pathname);
|
||||
if(paramsMatch) {
|
||||
params = getParams(route.params)(paramsMatch);
|
||||
}
|
||||
}
|
||||
validateGetStaticPathsModule(mod);
|
||||
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})`);
|
||||
|
||||
const result = {
|
||||
const result: SSRResult = {
|
||||
styles: new Set(),
|
||||
scripts: new Set(),
|
||||
/** 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 url = new URL('.' + pathname, site);
|
||||
const canonicalURL = getCanonicalURL(pathname, astroConfig.buildOptions.site || origin);
|
||||
|
@ -175,30 +189,46 @@ export async function ssr({ astroConfig, filePath, logging, mode, origin, pathna
|
|||
return {
|
||||
isPage: true,
|
||||
site,
|
||||
request: { url, canonicalURL },
|
||||
request: {
|
||||
canonicalURL,
|
||||
params: {},
|
||||
url
|
||||
},
|
||||
props,
|
||||
fetchContent,
|
||||
slots: Object.fromEntries(
|
||||
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 },
|
||||
};
|
||||
|
||||
const createFetchContent = (currentFilePath: string) => {
|
||||
function createFetchContent(currentFilePath: string) {
|
||||
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 cacheKey = `${cwd}:${pattern}`;
|
||||
if (fetchContentCache.has(cacheKey)) {
|
||||
return fetchContentCache.get(cacheKey);
|
||||
}
|
||||
const files = await glob(pattern, { cwd, absolute: true });
|
||||
const contents = await Promise.all(
|
||||
const contents: FetchContentResult<T>[] = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const { metadata: astro = {}, frontmatter = {} } = (await viteServer.ssrLoadModule(file)) as any;
|
||||
return { ...frontmatter, astro };
|
||||
const loadedModule = await viteServer.ssrLoadModule(file);
|
||||
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);
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
/**
|
||||
* UNCOMMENT: add support for custom elements
|
||||
import { expect } from 'chai';
|
||||
import cheerio from 'cheerio';
|
||||
import { loadFixture } from './test-utils.js';
|
||||
import path from 'path';
|
||||
|
||||
let fixture;
|
||||
|
||||
|
@ -49,17 +48,8 @@ describe('Custom Elements', () => {
|
|||
expect($('my-element template[shadowroot=open]')).to.have.lengthOf(1);
|
||||
|
||||
// Hydration
|
||||
// test 3: Component URL is 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);
|
||||
|
||||
// test 3: Component and polyfill scripts included
|
||||
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 () => {
|
||||
|
@ -67,10 +57,6 @@ describe('Custom Elements', () => {
|
|||
const $ = cheerio.load(html);
|
||||
|
||||
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 () => {
|
||||
|
@ -88,7 +74,4 @@ describe('Custom Elements', () => {
|
|||
// test 1: Element rendered
|
||||
expect($('client-only-element')).to.have.lengthOf(1);
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
it.skip('is skipped', () => {});
|
||||
});
|
|
@ -1,7 +1,6 @@
|
|||
|
||||
export default {
|
||||
name: '@astrojs/test-custom-element-renderer',
|
||||
server: './server',
|
||||
server: './server.js',
|
||||
polyfills: [
|
||||
'./polyfill.js'
|
||||
],
|
||||
|
|
|
@ -3,5 +3,11 @@
|
|||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"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…
Reference in a new issue