Renderer plugins (#231)

* refactor: pluggable renderers

* refactor: cache renderer per component

* docs: update comments on snowpack plugin `transform` method

* docs: add comments to renderer plugins

* refactor: convert components to Map

* fix: pass children through to astro __render

* refactor: move Components/ComponentInfo to shared types

* refactor: remove `gatherRuntimes` step, just scan output for imports

* refactor: update isComponentTag logic

* chore: move dependencies to renderers

* fix: cross-platform transform injection

* feat: defer renderer to react, fallback to preact

* fix: use double quotes in generated script

* test: fix failing children tests

* test: add workspaceRoot to all tests

* fix: pass props to renderer check

* chore: add test:core script back for convenience

* chore: remove unused external

* chore: rename renderers

* chore: add astring, estree-util-value-to-estree

* chore: render-component => __astro_component

* refactor: split hydrate logic to own file

* refactor: use `astro-fragment` rather than `div`

* chore: remove unused hooks

* chore: delete unused file

* chore: add changesets

* fix: Astro renderer should be async

* fix: remove <astro-fragment> for static content

* test: fix failing test

* chore: normalize config interface

* feat: allow renderers to inject a snowpackPlugin

* fix: resolve import URL before using dynamic import

* refactor: update renderers to use separate /server entrypoint

* refactor: update server renderer interface

* fix: get renderers working again

* test: debug failing test

* test: better debug

* test: better debug

* test: remove debug

* fix: support esm and cjs packages via "resolve"

* refactor: split hydrate functions into individual files

* fix: dependency resolution relative to projectRoot

* fix: @snowpack/plugin-postcss needs to be hoisted

* fix: do not test prettier-plugin-astro as it's not ready for primetime
This commit is contained in:
Nate Moore 2021-05-26 13:30:22 -05:00 committed by GitHub
parent 31e52c2e4c
commit 643c880f28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
73 changed files with 2466 additions and 2444 deletions

View file

@ -0,0 +1,18 @@
---
'astro': minor
---
**This is a breaking change**
Updated the rendering pipeline for `astro` to truly support any framework.
For the vast majority of use cases, `astro` should _just work_ out of the box. Astro now depends on `@astro-renderer/preact`, `@astro-renderer/react`, `@astro-renderer/svelte`, and `@astro-renderer/vue`, rather than these being built into the core library. This opens the door for anyone to contribute additional renderers for Astro to support their favorite framework, as well as the ability for users to control which renderers should be used.
**Features**
- Expose a pluggable interface for controlling server-side rendering and client-side hydration
- Allows components from different frameworks to be nested within each other.
> Note: `svelte` currently does support non-destructive hydration, so components from other frameworks cannot currently be nested inside of a Svelte component. See https://github.com/sveltejs/svelte/issues/4308.
**Breaking Changes**
- To improve compiler performance, improve framework support, and minimize JS payloads, any children passed to hydrated components are automatically wrapped with an `<astro-fragment>` element.

View file

@ -0,0 +1,8 @@
---
'@astro-renderer/preact': minor
'@astro-renderer/react': minor
'@astro-renderer/svelte': minor
'@astro-renderer/vue': minor
---
Initial release

View file

@ -1,6 +0,0 @@
export default {
extensions: {
'.jsx': 'react',
'.tsx': 'preact',
}
};

View file

@ -0,0 +1,3 @@
<div class="children">
<h1>Hello Astro (A)</h1>
</div>

View file

@ -0,0 +1,3 @@
<div class="children">
<h1>Hello Astro (B)</h1>
</div>

View file

@ -2,7 +2,7 @@ import { h, Fragment } from 'preact';
import { useState } from 'preact/hooks';
/** a counter written in Preact */
export default function PreactCounter({ children }) {
export function PreactCounter({ children }) {
const [count, setCount] = useState(0);
const add = () => setCount((i) => i + 1);
const subtract = () => setCount((i) => i - 1);

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
/** a counter written in React */
export default function ReactCounter({ children }) {
export function Counter({ children }) {
const [count, setCount] = useState(0);
const add = () => setCount((i) => i + 1);
const subtract = () => setCount((i) => i - 1);

View file

@ -0,0 +1,2 @@
export { default as A } from './A.astro';
export { default as B } from './B.astro';

View file

@ -1,6 +1,7 @@
---
import ReactCounter from '../components/ReactCounter.jsx';
import PreactCounter from '../components/PreactCounter.tsx';
import { A, B as Renamed } from '../components';
import * as react from '../components/ReactCounter.jsx';
import { PreactCounter } from '../components/PreactCounter.tsx';
import VueCounter from '../components/VueCounter.vue';
import SvelteCounter from '../components/SvelteCounter.svelte';
---
@ -30,22 +31,28 @@ import SvelteCounter from '../components/SvelteCounter.svelte';
</head>
<body>
<main>
<ReactCounter:load>
<react.Counter:visible>
<h1>Hello React!</h1>
<p>What's up?</p>
</ReactCounter:load>
</react.Counter:visible>
<PreactCounter:load>
<h1>Hello Preact!</h1>
</PreactCounter:load>
<PreactCounter:visible>
<h1>Hello Preact!</h1>
</PreactCounter:visible>
<VueCounter:load>
<VueCounter:visible>
<h1>Hello Vue!</h1>
</VueCounter:load>
</VueCounter:visible>
<SvelteCounter:load>
<SvelteCounter:visible>
<h1>Hello Svelte!</h1>
</SvelteCounter:load>
</SvelteCounter:visible>
<A />
<Renamed />
</main>
</body>
</html>

View file

@ -10,10 +10,12 @@
"dev:vscode": "lerna run dev --scope astro-languageserver --scope astro-vscode --scope astro-parser --parallel --stream",
"format": "prettier -w \"**/*.{js,jsx,ts,tsx,md,json}\"",
"lint": "eslint \"packages/**/*.ts\"",
"test": "lerna run test --scope astro --scope prettier-plugin-astro --scope create-astro --stream"
"test": "lerna run test --scope astro --scope create-astro --stream",
"test:core": "yarn workspace astro run test"
},
"workspaces": [
"packages/*",
"packages/renderers/*",
"examples/*",
"tools/*",
"scripts",

View file

@ -32,6 +32,10 @@
"test": "uvu test -i fixtures -i benchmark -i test-utils.js"
},
"dependencies": {
"@astro-renderer/preact": "0.0.1",
"@astro-renderer/react": "0.0.1",
"@astro-renderer/svelte": "0.0.1",
"@astro-renderer/vue": "0.0.1",
"@babel/code-frame": "^7.12.13",
"@babel/generator": "^7.13.9",
"@babel/parser": "^7.13.15",
@ -39,10 +43,8 @@
"@silvenon/remark-smartypants": "^1.0.0",
"@snowpack/plugin-postcss": "^1.4.0",
"@snowpack/plugin-sass": "^1.4.0",
"@snowpack/plugin-svelte": "^3.7.0",
"@snowpack/plugin-vue": "^2.5.0",
"@vue/server-renderer": "^3.0.10",
"acorn": "^7.4.0",
"astring": "^1.7.4",
"astro-parser": "0.11.0",
"astro-prism": "0.0.2",
"autoprefixer": "^10.2.5",
@ -50,6 +52,7 @@
"del": "^6.0.0",
"es-module-lexer": "^0.4.1",
"esbuild": "^0.10.1",
"estree-util-value-to-estree": "^1.2.0",
"estree-walker": "^3.0.0",
"fast-xml-parser": "^3.19.0",
"fdir": "^5.0.0",
@ -69,11 +72,7 @@
"picomatch": "^2.2.3",
"postcss": "^8.2.15",
"postcss-icss-keyframes": "^0.2.1",
"preact": "^10.5.13",
"preact-render-to-string": "^5.1.18",
"prismjs": "^1.23.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"rehype-parse": "^7.0.1",
"rehype-raw": "^5.1.0",
"rehype-stringify": "^8.0.0",
@ -81,6 +80,7 @@
"remark-gfm": "^1.0.0",
"remark-parse": "^9.0.0",
"remark-rehype": "^8.1.0",
"resolve": "^1.20.0",
"rollup": "^2.43.1",
"rollup-plugin-terser": "^7.0.2",
"sass": "^1.32.13",
@ -89,10 +89,8 @@
"snowpack": "^3.5.1",
"source-map-support": "^0.5.19",
"string-width": "^5.0.0",
"svelte": "^3.35.0",
"tiny-glob": "^0.2.8",
"unified": "^9.2.1",
"vue": "^3.0.10",
"yargs-parser": "^20.2.7"
},
"devDependencies": {

View file

@ -3,14 +3,45 @@ const { readFile } = require('fs').promises;
// Snowpack plugins must be CommonJS :(
const transformPromise = import('./dist/compiler/index.js');
module.exports = function (snowpackConfig, { resolvePackageUrl, extensions, astroConfig } = {}) {
module.exports = function (snowpackConfig, { resolvePackageUrl, renderers, astroConfig } = {}) {
return {
name: 'snowpack-astro',
knownEntrypoints: [],
resolve: {
input: ['.astro', '.md'],
output: ['.js', '.css'],
},
/**
* This injects our renderer plugins to the Astro runtime (as a bit of a hack).
*
* In a world where Snowpack supports virtual files, this won't be necessary and
* should be refactored to a virtual file that is imported by the runtime.
*
* Take a look at `/src/frontend/__astro_component.ts`. It relies on both
* `__rendererSources` and `__renderers` being defined, so we're creating those here.
*
* The output of this is the following (or something very close to it):
*
* ```js
* import * as __renderer_0 from '/_snowpack/link/packages/renderers/vue/index.js';
* import * as __renderer_1 from '/_snowpack/link/packages/renderers/svelte/index.js';
* import * as __renderer_2 from '/_snowpack/link/packages/renderers/preact/index.js';
* import * as __renderer_3 from '/_snowpack/link/packages/renderers/react/index.js';
* let __rendererSources = ["/_snowpack/link/packages/renderers/vue/client.js", "/_snowpack/link/packages/renderers/svelte/client.js", "/_snowpack/link/packages/renderers/preact/client.js", "/_snowpack/link/packages/renderers/react/client.js"];
* let __renderers = [__renderer_0, __renderer_1, __renderer_2, __renderer_3];
* // the original file contents
* ```
*/
async transform({contents, id, fileExt}) {
if (fileExt === '.js' && /__astro_component\.js/g.test(id)) {
const rendererServerPackages = await Promise.all(renderers.map(({ server }) => resolvePackageUrl(server)));
const rendererClientPackages = await Promise.all(renderers.map(({ client }) => resolvePackageUrl(client)));
const result = `${rendererServerPackages.map((pkg, i) => `import __renderer_${i} from "${pkg}";`).join('\n')}
let __rendererSources = [${rendererClientPackages.map(pkg => `"${pkg}"`).join(', ')}];
let __renderers = [${rendererServerPackages.map((_, i) => `__renderer_${i}`).join(', ')}];
${contents}`;
return result;
}
},
async load({ filePath }) {
const { compileComponent } = await transformPromise;
const projectRoot = snowpackConfig.root;
@ -18,7 +49,7 @@ module.exports = function (snowpackConfig, { resolvePackageUrl, extensions, astr
const compileOptions = {
astroConfig,
resolvePackageUrl,
extensions,
renderers,
};
const result = await compileComponent(contents, { compileOptions, filename: filePath, projectRoot });
const output = {

View file

@ -1,3 +1,5 @@
import type { ImportSpecifier, ImportDefaultSpecifier, ImportNamespaceSpecifier } from '@babel/types';
export interface AstroConfigRaw {
dist: string;
projectRoot: string;
@ -6,8 +8,6 @@ export interface AstroConfigRaw {
jsx?: string;
}
export type ValidExtensionPlugins = 'astro' | 'react' | 'preact' | 'svelte' | 'vue';
export interface AstroMarkdownOptions {
/** Enable or disable footnotes syntax extension */
footnotes: boolean;
@ -19,7 +19,7 @@ export interface AstroConfig {
projectRoot: URL;
astroRoot: URL;
public: URL;
extensions?: Record<string, ValidExtensionPlugins>;
renderers?: string[];
/** Options for rendering markdown content */
markdownOptions?: Partial<AstroMarkdownOptions>;
/** Options specific to `astro build` */
@ -171,3 +171,10 @@ export interface CollectionResult<T = any> {
/** Matched parameters, if any */
params: Params;
}
export interface ComponentInfo {
url: string;
importSpecifier: ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier;
}
export type Components = Map<string, ComponentInfo>;

View file

@ -1,11 +1,10 @@
import type { LogOptions } from '../logger';
import type { AstroConfig, RuntimeMode, ValidExtensionPlugins } from './astro';
import type { AstroConfig, RuntimeMode } from './astro';
export interface CompileOptions {
logging: LogOptions;
resolvePackageUrl: (p: string) => Promise<string>;
astroConfig: AstroConfig;
extensions?: Record<string, ValidExtensionPlugins>;
mode: RuntimeMode;
tailwindConfig?: string;
}

View file

@ -0,0 +1 @@
export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: string|null) => void>;

View file

@ -0,0 +1 @@
declare module 'resolve';

View file

@ -6,6 +6,7 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { performance } from 'perf_hooks';
import eslexer from 'es-module-lexer';
import cheerio from 'cheerio';
import del from 'del';
import { bold, green, yellow } from 'kleur/colors';
@ -94,6 +95,8 @@ export async function build(astroConfig: AstroConfig, logging: LogOptions = defa
// after pages are built, build depTree
timer.deps = performance.now();
const scanPromises: Promise<void>[] = [];
await eslexer.init;
for (const id of Object.keys(buildState)) {
if (buildState[id].contentType !== 'text/html') continue; // only scan HTML files
const pageDeps = findDeps(buildState[id].contents as string, {
@ -237,8 +240,16 @@ export function findDeps(html: string, { astroConfig, srcPath }: { astroConfig:
$('script').each((i, el) => {
const src = $(el).attr('src');
if (src && !isRemote(src)) {
if (src) {
if (isRemote(src)) return;
pageDeps.js.add(getDistPath(src, { astroConfig, srcPath }));
} else {
const text = $(el).html();
if (!text) return;
const [imports] = eslexer.parse(text);
for (const spec of imports) {
if (spec.n) pageDeps.js.add(spec.n);
}
}
});

View file

@ -1,22 +1,8 @@
import type { ImportDeclaration } from '@babel/types';
import type { AstroConfig, BuildOutput, RuntimeMode, ValidExtensionPlugins } from '../@types/astro';
import type { AstroConfig, BuildOutput, RuntimeMode } from '../@types/astro';
import type { AstroRuntime, LoadResult } from '../runtime';
import type { LogOptions } from '../logger';
import fs from 'fs';
import path from 'path';
import mime from 'mime';
import { fileURLToPath } from 'url';
import babelParser from '@babel/parser';
import { parse } from 'astro-parser';
import esbuild from 'esbuild';
import { walk } from 'estree-walker';
import { generateRSS } from './rss.js';
import { getAttrValue } from '../ast.js';
import { convertMdToAstroSource } from '../compiler/index.js';
import { transform } from '../compiler/transform/index.js';
type DynamicImportMap = Map<'vue' | 'react' | 'react-dom' | 'preact' | 'svelte', string>;
interface PageBuildOptions {
astroConfig: AstroConfig;
@ -62,7 +48,6 @@ export async function buildCollectionPage({ astroConfig, filepath, logging, mode
const [result] = await Promise.all([
loadCollection(outURL) as Promise<LoadResult>, // first run will always return a result so assert type here
gatherRuntimes({ astroConfig, buildState, filepath, logging, resolvePackageUrl, mode, runtime }),
]);
if (result.statusCode >= 500) {
@ -103,11 +88,11 @@ export async function buildCollectionPage({ astroConfig, filepath, logging, mode
}
/** Build static page */
export async function buildStaticPage({ astroConfig, buildState, filepath, logging, mode, resolvePackageUrl, runtime }: PageBuildOptions): Promise<void> {
export async function buildStaticPage({ astroConfig, buildState, filepath, runtime }: PageBuildOptions): Promise<void> {
const pagesPath = new URL('./pages/', astroConfig.astroRoot);
const url = filepath.pathname.replace(pagesPath.pathname, '/').replace(/(index)?\.(astro|md)$/, '');
// build page in parallel with gathering runtimes
// build page in parallel
await Promise.all([
runtime.load(url).then((result) => {
if (result.statusCode !== 200) throw new Error((result as any).error);
@ -118,243 +103,6 @@ export async function buildStaticPage({ astroConfig, buildState, filepath, loggi
contentType: 'text/html',
encoding: 'utf8',
};
}),
gatherRuntimes({ astroConfig, buildState, filepath, logging, resolvePackageUrl, mode, runtime }),
})
]);
}
/** Evaluate mustache expression (safely) */
function compileExpressionSafe(raw: string): string {
let { code } = esbuild.transformSync(raw, {
loader: 'tsx',
jsxFactory: 'h',
jsxFragment: 'Fragment',
charset: 'utf8',
});
return code;
}
/** Add framework runtimes when needed */
async function acquireDynamicComponentImports(plugins: Set<ValidExtensionPlugins>, resolvePackageUrl: (s: string) => Promise<string>): Promise<DynamicImportMap> {
const importMap: DynamicImportMap = new Map();
for (let plugin of plugins) {
switch (plugin) {
case 'svelte': {
importMap.set('svelte', await resolvePackageUrl('svelte'));
break;
}
case 'vue': {
importMap.set('vue', await resolvePackageUrl('vue'));
break;
}
case 'react': {
importMap.set('react', await resolvePackageUrl('react'));
importMap.set('react-dom', await resolvePackageUrl('react-dom'));
break;
}
case 'preact': {
importMap.set('preact', await resolvePackageUrl('preact'));
break;
}
}
}
return importMap;
}
const defaultExtensions: Readonly<Record<string, ValidExtensionPlugins>> = {
'.jsx': 'react',
'.tsx': 'react',
'.svelte': 'svelte',
'.vue': 'vue',
};
/** Gather necessary framework runtimes (React, Vue, Svelte, etc.) for dynamic components */
async function gatherRuntimes({ astroConfig, buildState, filepath, logging, resolvePackageUrl, mode, runtime }: PageBuildOptions): Promise<Set<string>> {
const imports = new Set<string>();
// Only astro files
if (!filepath.pathname.endsWith('.astro') && !filepath.pathname.endsWith('.md')) {
return imports;
}
const extensions = astroConfig.extensions || defaultExtensions;
let source = await fs.promises.readFile(filepath, 'utf8');
if (filepath.pathname.endsWith('.md')) {
source = await convertMdToAstroSource(source, { filename: fileURLToPath(filepath) });
}
const ast = parse(source, { filepath });
if (!ast.module) {
return imports;
}
await transform(ast, {
filename: fileURLToPath(filepath),
fileID: '',
compileOptions: {
astroConfig,
resolvePackageUrl,
logging,
mode,
},
});
const componentImports: ImportDeclaration[] = [];
const components: Record<string, { plugin: ValidExtensionPlugins; type: string; specifier: string }> = {};
const plugins = new Set<ValidExtensionPlugins>();
const program = babelParser.parse(ast.module.content, {
sourceType: 'module',
plugins: ['jsx', 'typescript', 'topLevelAwait'],
}).program;
const { body } = program;
let i = body.length;
while (--i >= 0) {
const node = body[i];
if (node.type === 'ImportDeclaration') {
componentImports.push(node);
}
}
for (const componentImport of componentImports) {
const importUrl = componentImport.source.value;
const componentType = path.posix.extname(importUrl);
for (const specifier of componentImport.specifiers) {
if (specifier.type === 'ImportDefaultSpecifier') {
const componentName = specifier.local.name;
const plugin = extensions[componentType] || defaultExtensions[componentType];
plugins.add(plugin);
components[componentName] = {
plugin,
type: componentType,
specifier: importUrl,
};
break;
}
}
}
const dynamic = await acquireDynamicComponentImports(plugins, resolvePackageUrl);
/** Add dynamic component runtimes to imports */
function appendImports(rawName: string, importUrl: URL) {
const [componentName, componentType] = rawName.split(':');
if (!componentType) {
return;
}
if (!components[componentName]) {
throw new Error(`Unknown Component: ${componentName}`);
}
const defn = components[componentName];
const fileUrl = new URL(defn.specifier, importUrl);
let rel = path.posix.relative(astroConfig.astroRoot.pathname, fileUrl.pathname);
switch (defn.plugin) {
case 'preact': {
const preact = dynamic.get('preact');
if (!preact) throw new Error(`Unable to load Preact plugin`);
imports.add(preact);
rel = rel.replace(/\.[^.]+$/, '.js');
break;
}
case 'react': {
const [react, reactDOM] = [dynamic.get('react'), dynamic.get('react-dom')];
if (!react || !reactDOM) throw new Error(`Unable to load React plugin`);
imports.add(react);
imports.add(reactDOM);
rel = rel.replace(/\.[^.]+$/, '.js');
break;
}
case 'vue': {
const vue = dynamic.get('vue');
if (!vue) throw new Error('Unable to load Vue plugin');
imports.add(vue);
rel = rel.replace(/\.[^.]+$/, '.vue.js');
break;
}
case 'svelte': {
const svelte = dynamic.get('svelte');
if (!svelte) throw new Error('Unable to load Svelte plugin');
imports.add(svelte);
imports.add('/_astro_internal/runtime/svelte.js');
rel = rel.replace(/\.[^.]+$/, '.svelte.js');
break;
}
}
imports.add(`/_astro/${rel}`);
}
walk(ast.html, {
enter(node) {
switch (node.type) {
case 'Element': {
if (node.name !== 'script') return;
if (getAttrValue(node.attributes, 'type') !== 'module') return;
const src = getAttrValue(node.attributes, 'src');
if (src && src.startsWith('/')) {
imports.add(src);
}
break;
}
case 'MustacheTag': {
let code: string;
try {
code = compileExpressionSafe(node.content);
} catch {
return;
}
let matches: RegExpExecArray[] = [];
let match: RegExpExecArray | null | undefined;
const H_COMPONENT_SCANNER = /h\(['"]?([A-Z].*?)['"]?,/gs;
const regex = new RegExp(H_COMPONENT_SCANNER);
while ((match = regex.exec(code))) {
matches.push(match);
}
for (const foundImport of matches.reverse()) {
const name = foundImport[1];
appendImports(name, filepath);
}
break;
}
case 'InlineComponent': {
if (/^[A-Z]/.test(node.name)) {
appendImports(node.name, filepath);
return;
}
break;
}
}
},
});
// add all imports to build output
await Promise.all(
[...imports].map(async (url) => {
if (buildState[url]) return; // dont build already-built URLs
// add new results to buildState
const result = await runtime.load(url);
if (result.statusCode === 200) {
buildState[url] = {
srcPath: filepath,
contents: result.contents,
contentType: result.contentType || mime.getType(url) || '',
encoding: 'utf8',
};
}
})
);
return imports;
}

View file

@ -1,6 +1,7 @@
import type { Ast, Script, Style, TemplateNode } from 'astro-parser';
import type { CompileOptions } from '../../@types/compiler';
import type { AstroConfig, AstroMarkdownOptions, TransformResult, ValidExtensionPlugins } from '../../@types/astro';
import type { AstroConfig, AstroMarkdownOptions, TransformResult, ComponentInfo, Components } from '../../@types/astro';
import type { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier } from '@babel/types';
import 'source-map-support/register.js';
import eslexer from 'es-module-lexer';
@ -12,12 +13,11 @@ import _babelGenerator from '@babel/generator';
import babelParser from '@babel/parser';
import { codeFrameColumns } from '@babel/code-frame';
import * as babelTraverse from '@babel/traverse';
import { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier } from '@babel/types';
import { error, warn } from '../../logger.js';
import { fetchContent } from './content.js';
import { isFetchContent } from './utils.js';
import { yellow } from 'kleur/colors';
import { MarkdownRenderingOptions, renderMarkdown } from '../utils';
import { isComponentTag, renderMarkdown } from '../utils';
const traverse: typeof babelTraverse.default = (babelTraverse.default as any).default;
@ -126,134 +126,44 @@ function generateAttributes(attrs: Record<string, string>): string {
return result + '}';
}
interface ComponentInfo {
type: string;
url: string;
plugin: string | undefined;
}
const defaultExtensions: Readonly<Record<string, ValidExtensionPlugins>> = {
'.astro': 'astro',
'.jsx': 'react',
'.tsx': 'react',
'.vue': 'vue',
'.svelte': 'svelte',
};
type DynamicImportMap = Map<'vue' | 'react' | 'react-dom' | 'preact' | 'svelte', string>;
interface GetComponentWrapperOptions {
filename: string;
astroConfig: AstroConfig;
dynamicImports: DynamicImportMap;
}
const PlainExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']);
/** Generate Astro-friendly component import */
function getComponentWrapper(_name: string, { type, plugin, url }: ComponentInfo, opts: GetComponentWrapperOptions) {
const { astroConfig, dynamicImports, filename } = opts;
function getComponentWrapper(_name: string, { url, importSpecifier }: ComponentInfo, opts: GetComponentWrapperOptions) {
const { astroConfig, filename } = opts;
const { astroRoot } = astroConfig;
const [name, kind] = _name.split(':');
const currFileUrl = new URL(`file://${filename}`);
if (!plugin) {
throw new Error(`No supported plugin found for ${type ? `extension ${type}` : `${url} (try adding an extension)`}`);
}
const getComponentUrl = (ext = '.js') => {
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/' + path.posix.relative(astroRoot.pathname, outUrl.pathname).replace(/\.[^.]+$/, ext);
};
switch (plugin) {
case 'astro': {
if (kind) {
throw new Error(`Astro does not support :${kind}`);
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 };
}
return {
wrapper: name,
wrapperImport: ``,
};
}
case 'preact': {
if (['load', 'idle', 'visible'].includes(kind)) {
return {
wrapper: `__preact_${kind}(${name}, ${JSON.stringify({
componentUrl: getComponentUrl(),
componentExport: 'default',
frameworkUrls: {
preact: dynamicImports.get('preact'),
},
})})`,
wrapperImport: `import {__preact_${kind}} from '${internalImport('render/preact.js')}';`,
};
case 'ImportNamespaceSpecifier': {
const [_, value] = name.split('.');
return { value };
}
}
}
return {
wrapper: `__preact_static(${name})`,
wrapperImport: `import {__preact_static} from '${internalImport('render/preact.js')}';`,
};
}
case 'react': {
if (['load', 'idle', 'visible'].includes(kind)) {
return {
wrapper: `__react_${kind}(${name}, ${JSON.stringify({
componentUrl: getComponentUrl(),
componentExport: 'default',
frameworkUrls: {
react: dynamicImports.get('react'),
'react-dom': dynamicImports.get('react-dom'),
},
})})`,
wrapperImport: `import {__react_${kind}} from '${internalImport('render/react.js')}';`,
};
}
return {
wrapper: `__react_static(${name})`,
wrapperImport: `import {__react_static} from '${internalImport('render/react.js')}';`,
};
}
case 'svelte': {
if (['load', 'idle', 'visible'].includes(kind)) {
return {
wrapper: `__svelte_${kind}(${name}, ${JSON.stringify({
componentUrl: getComponentUrl('.svelte.js'),
componentExport: 'default',
frameworkUrls: {
'svelte-runtime': internalImport('runtime/svelte.js'),
},
})})`,
wrapperImport: `import {__svelte_${kind}} from '${internalImport('render/svelte.js')}';`,
};
}
return {
wrapper: `__svelte_static(${name})`,
wrapperImport: `import {__svelte_static} from '${internalImport('render/svelte.js')}';`,
};
}
case 'vue': {
if (['load', 'idle', 'visible'].includes(kind)) {
return {
wrapper: `__vue_${kind}(${name}, ${JSON.stringify({
componentUrl: getComponentUrl('.vue.js'),
componentExport: 'default',
frameworkUrls: {
vue: dynamicImports.get('vue'),
},
})})`,
wrapperImport: `import {__vue_${kind}} from '${internalImport('render/vue.js')}';`,
};
}
return {
wrapper: `__vue_static(${name})`,
wrapperImport: `import {__vue_static} from '${internalImport('render/vue.js')}';`,
};
}
default: {
throw new Error(`Unknown component type`);
}
const importInfo = kind ? { componentUrl: getComponentUrl(), componentExport: getComponentExport() } : {};
return {
wrapper: `__astro_component(${name}, ${JSON.stringify({ hydrate: kind, displayName: name, ...importInfo })})`,
wrapperImport: `import {__astro_component} from '${internalImport('__astro_component.js')}';`,
}
}
@ -268,38 +178,8 @@ function compileExpressionSafe(raw: string): string {
return code;
}
/** Build dependency map of dynamic component runtime frameworks */
async function acquireDynamicComponentImports(plugins: Set<ValidExtensionPlugins>, resolvePackageUrl: (s: string) => Promise<string>): Promise<DynamicImportMap> {
const importMap: DynamicImportMap = new Map();
for (let plugin of plugins) {
switch (plugin) {
case 'vue': {
importMap.set('vue', await resolvePackageUrl('vue'));
break;
}
case 'react': {
importMap.set('react', await resolvePackageUrl('react'));
importMap.set('react-dom', await resolvePackageUrl('react-dom'));
break;
}
case 'preact': {
importMap.set('preact', await resolvePackageUrl('preact'));
break;
}
case 'svelte': {
importMap.set('svelte', await resolvePackageUrl('svelte'));
break;
}
}
}
return importMap;
}
type Components = Record<string, { type: string; url: string; plugin: string | undefined }>;
interface CompileResult {
script: string;
componentPlugins: Set<ValidExtensionPlugins>;
createCollection?: string;
}
@ -311,25 +191,20 @@ interface CodegenState {
insideMarkdown: boolean | Record<string, any>;
};
importExportStatements: Set<string>;
dynamicImports: DynamicImportMap;
}
/** Compile/prepare Astro frontmatter scripts */
function compileModule(module: Script, state: CodegenState, compileOptions: CompileOptions): CompileResult {
const { extensions = defaultExtensions } = compileOptions;
const componentImports: ImportDeclaration[] = [];
const componentProps: VariableDeclarator[] = [];
const componentExports: ExportNamedDeclaration[] = [];
const contentImports = new Map<string, { spec: string; declarator: string }>();
const importSpecifierTypes = new Set(['ImportDefaultSpecifier', 'ImportSpecifier']);
let script = '';
let propsStatement = '';
let contentCode = ''; // code for handling Astro.fetchContent(), if any;
let createCollection = ''; // function for executing collection
const componentPlugins = new Set<ValidExtensionPlugins>();
if (module) {
const parseOptions: babelParser.ParserOptions = {
@ -420,19 +295,12 @@ function compileModule(module: Script, state: CodegenState, compileOptions: Comp
for (const componentImport of componentImports) {
const importUrl = componentImport.source.value;
const componentType = path.posix.extname(importUrl);
const specifier = componentImport.specifiers[0];
if (!specifier) continue; // this is unused
// set componentName to default import if used (user), or use filename if no default import (mostly internal use)
const componentName = importSpecifierTypes.has(specifier.type) ? specifier.local.name : path.posix.basename(importUrl, componentType);
const plugin = extensions[componentType] || defaultExtensions[componentType];
state.components[componentName] = {
type: componentType,
plugin,
url: importUrl,
};
if (plugin) {
componentPlugins.add(plugin);
for (const specifier of componentImport.specifiers) {
const componentName = specifier.local.name;
state.components.set(componentName, {
importSpecifier: specifier,
url: importUrl,
});
}
const { start, end } = componentImport;
state.importExportStatements.add(module.content.slice(start || undefined, end || undefined));
@ -518,7 +386,6 @@ function compileModule(module: Script, state: CodegenState, compileOptions: Comp
return {
script,
componentPlugins,
createCollection: createCollection || undefined,
};
}
@ -550,7 +417,7 @@ function dedent(str: string) {
/** Compile page markup */
async function compileHtml(enterNode: TemplateNode, state: CodegenState, compileOptions: CompileOptions): Promise<string> {
return new Promise((resolve) => {
const { components, css, importExportStatements, dynamicImports, filename } = state;
const { components, css, importExportStatements, filename } = state;
const { astroConfig } = compileOptions;
let paren = -1;
@ -625,8 +492,7 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
paren++;
return;
}
const COMPONENT_NAME_SCANNER = /^[A-Z]/;
if (!COMPONENT_NAME_SCANNER.test(name)) {
if (!isComponentTag(name)) {
if (curr === 'markdown') {
await pushMarkdownToBuffer();
}
@ -635,19 +501,21 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
return;
}
const [componentName, componentKind] = name.split(':');
const componentImportData = components[componentName];
if (!componentImportData) {
let componentInfo = components.get(componentName);
if (/\./.test(componentName)) {
const [componentNamespace] = componentName.split('.');
componentInfo = components.get(componentNamespace);
}
if (!componentInfo) {
throw new Error(`Unknown Component: ${componentName}`);
}
if (componentImportData.type === '.astro') {
if (componentName === 'Markdown') {
const { $scope } = attributes ?? {};
state.markers.insideMarkdown = { $scope };
curr = 'markdown';
return;
}
if (componentName === 'Markdown') {
const { $scope } = attributes ?? {};
state.markers.insideMarkdown = { $scope };
curr = 'markdown';
return;
}
const { wrapper, wrapperImport } = getComponentWrapper(name, components[componentName], { astroConfig, dynamicImports, filename });
const { wrapper, wrapperImport } = getComponentWrapper(name, componentInfo, { astroConfig, filename });
if (wrapperImport) {
importExportStatements.add(wrapperImport);
}
@ -762,17 +630,15 @@ export async function codegen(ast: Ast, { compileOptions, filename }: CodeGenOpt
const state: CodegenState = {
filename,
components: {},
components: new Map(),
css: [],
markers: {
insideMarkdown: false,
},
importExportStatements: new Set(),
dynamicImports: new Map(),
};
const { script, componentPlugins, createCollection } = compileModule(ast.module, state, compileOptions);
state.dynamicImports = await acquireDynamicComponentImports(componentPlugins, compileOptions.resolvePackageUrl);
const { script, createCollection } = compileModule(ast.module, state, compileOptions);
compileCss(ast.css, state);

View file

@ -130,7 +130,7 @@ async function __render(props, ...children) {
${result.script}
return h(Fragment, null, ${result.html});
}
export default __render;
export default { isAstroComponent: true, __render };
${result.createCollection || ''}
@ -138,6 +138,7 @@ ${result.createCollection || ''}
// triggered by loading a component directly by URL.
export async function __renderPage({request, children, props}) {
const currentChild = {
isAstroComponent: true,
layout: typeof __layout === 'undefined' ? undefined : __layout,
content: typeof __content === 'undefined' ? undefined : __content,
__render,

View file

@ -0,0 +1,63 @@
import { Transformer } from '../../@types/transformer';
import type { TemplateNode } from 'astro-parser';
/** If there are hydrated components, inject styles for [data-astro-root] and [data-astro-children] */
export default function (): Transformer {
let head: TemplateNode;
let body: TemplateNode;
let hasComponents = false;
return {
visitors: {
html: {
InlineComponent: {
enter(node, parent) {
const [name, kind] = node.name.split(':');
if (kind && !hasComponents) {
hasComponents = true;
}
}
},
Element: {
enter(node) {
if (!hasComponents) return;
switch (node.name) {
case 'head': {
head = node;
return;
}
case 'body': {
body = node;
return;
}
default: return;
}
}
}
},
},
async finalize() {
if (!(head && hasComponents)) return;
const style: TemplateNode = {
type: 'Element',
name: 'style',
attributes: [
{ name: 'type', type: 'Attribute', value: [{ type: 'Text', raw: 'text/css', data: 'text/css' }] },
],
start: 0,
end: 0,
children: [
{
start: 0,
end: 0,
type: 'Text',
data: 'astro-root, astro-fragment { display: contents; }',
raw: 'astro-root, astro-fragment { display: contents; }'
}
]
};
head.children = [...(head.children ?? []), style];
},
};
}

View file

@ -8,6 +8,7 @@ import transformStyles from './styles.js';
import transformDoctype from './doctype.js';
import transformModuleScripts from './module-scripts.js';
import transformCodeBlocks from './prism.js';
import transformHydration from './hydration.js';
interface VisitorCollection {
enter: Map<string, VisitorFn[]>;
@ -84,7 +85,7 @@ export async function transform(ast: Ast, opts: TransformOptions) {
const cssVisitors = createVisitorCollection();
const finalizers: Array<() => Promise<void>> = [];
const optimizers = [transformStyles(opts), transformDoctype(opts), transformModuleScripts(opts), transformCodeBlocks(ast.module)];
const optimizers = [transformHydration(), transformStyles(opts), transformDoctype(opts), transformModuleScripts(opts), transformCodeBlocks(ast.module)];
for (const optimizer of optimizers) {
collectVisitors(optimizer, htmlVisitors, cssVisitors, finalizers);

View file

@ -66,3 +66,8 @@ export async function renderMarkdown(content: string, opts?: MarkdownRenderingOp
content: result.toString(),
};
}
/** Is the given string a valid component tag */
export function isComponentTag(tag: string) {
return /^[A-Z]/.test(tag) || /^[a-z]+\./.test(tag);
}

View file

@ -1,7 +0,0 @@
<script>
const { __astro_component: Component, __astro_children, ...props } = $$props;
</script>
<svelte:component this={Component} {...props}>
{@html __astro_children}
</svelte:component>

View file

@ -0,0 +1,100 @@
import hash from 'shorthash';
import { valueToEstree, Value } from 'estree-util-value-to-estree';
import { generate } from 'astring';
import * as astro from './renderer-astro';
// 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));
/**
* These values are dynamically injected by Snowpack.
* See comment in `snowpack-plugin.cjs`!
*
* In a world where Snowpack supports virtual files, this won't be necessary.
* It would ideally look something like:
*
* ```ts
* import { __rendererSources, __renderers } from "virtual:astro/runtime"
* ```
*/
declare let __rendererSources: string[];
declare let __renderers: any[];
__rendererSources = ['', ...__rendererSources];
__renderers = [astro, ...__renderers];
const rendererCache = new WeakMap();
/** For a given component, resolve the renderer. Results are cached if this instance is encountered again */
function resolveRenderer(Component: any, props: any = {}) {
if (rendererCache.has(Component)) {
return rendererCache.get(Component);
}
for (const __renderer of __renderers) {
const shouldUse = __renderer.check(Component, props)
if (shouldUse) {
rendererCache.set(Component, __renderer);
return __renderer;
}
}
}
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)];
const script = `<script type="module">
import setup from '/_astro_internal/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);
});
</script>`;
return script;
}
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?`);
}
// First attempt at resolving a renderer (we don't have the props yet, so it might fail if they are required)
let renderer = resolveRenderer(Component);
return async (props: any, ..._children: string[]) => {
if (!renderer) {
// Second attempt at resolving a renderer (this time we have props!)
renderer = resolveRenderer(Component, props);
// Okay now we definitely can't resolve a renderer, so let's throw
if (!renderer) {
const name = typeof Component === 'function' ? (Component.displayName ?? Component.name) : `{ ${Object.keys(Component).join(', ')} }`;
throw new Error(`No renderer found for ${name}! Did you forget to add a renderer to your Astro config?`);
}
}
const children = _children.join('\n');
const { html } = await renderer.renderToStaticMarkup(Component, props, children);
// 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
return html.replace(/\<\/?astro-fragment\>/g, '');
};
// 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 astroRoot = `<astro-root uid="${astroId}">${html}</astro-root>`;
return [astroRoot, script].join('\n');
}
}

View file

@ -0,0 +1,23 @@
import type { GetHydrateCallback } from '../../@types/hydrate';
/**
* Hydrate this component as soon as the main thread is free
* (or after a short delay, if `requestIdleCallback`) isn't supported
*/
export default async function onIdle(astroId: string, getHydrateCallback: GetHydrateCallback) {
const cb = async () => {
const roots = document.querySelectorAll(`astro-root[uid="${astroId}"]`);
const innerHTML = roots[0].querySelector(`astro-fragment`)?.innerHTML ?? null;
const hydrate = await getHydrateCallback();
for (const root of roots) {
hydrate(root, innerHTML);
}
};
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(cb);
} else {
setTimeout(cb, 200);
}
}

View file

@ -0,0 +1,14 @@
import type { GetHydrateCallback } from '../../@types/hydrate';
/**
* Hydrate this component immediately
*/
export default async function onLoad(astroId: string, getHydrateCallback: GetHydrateCallback) {
const roots = document.querySelectorAll(`astro-root[uid="${astroId}"]`);
const innerHTML = roots[0].querySelector(`astro-fragment`)?.innerHTML ?? null;
const hydrate = await getHydrateCallback();
for (const root of roots) {
hydrate(root, innerHTML);
}
}

View file

@ -0,0 +1,32 @@
import type { GetHydrateCallback } from '../../@types/hydrate';
/**
* Hydrate this component when one of it's children becomes visible.
* We target the children because `astro-root` is set to `display: contents`
* which doesn't work with IntersectionObserver
*/
export default async function onVisible(astroId: string, getHydrateCallback: GetHydrateCallback) {
const roots = document.querySelectorAll(`astro-root[uid="${astroId}"]`);
const innerHTML = roots[0].querySelector(`astro-fragment`)?.innerHTML ?? null;
const cb = async () => {
const hydrate = await getHydrateCallback();
for (const root of roots) {
hydrate(root, innerHTML);
}
};
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
// As soon as we hydrate, disconnect this IntersectionObserver for every `astro-root`
io.disconnect();
cb();
});
for (const root of roots) {
for (let i = 0; i < root.children.length; i++) {
const child = root.children[i];
io.observe(child);
}
}
}

View file

@ -1,31 +0,0 @@
import { h, render, ComponentType } from 'preact';
import { renderToString } from 'preact-render-to-string';
import { childrenToVnodes } from './utils';
import type { ComponentRenderer } from '../../@types/renderer';
import { createRenderer } from './renderer';
// This prevents tree-shaking of render.
Function.prototype(render);
const Preact: ComponentRenderer<ComponentType> = {
jsxPragma: h,
jsxPragmaName: 'h',
renderStatic(Component) {
return async (props, ...children) => {
return renderToString(h(Component, props, childrenToVnodes(h, children)));
};
},
imports: {
preact: ['render', 'Fragment', 'h'],
},
render({ Component, root, props, children }) {
return `render(h(${Component}, ${props}, h(Fragment, null, ...${children})), ${root})`;
},
};
const renderer = createRenderer(Preact);
export const __preact_static = renderer.static;
export const __preact_load = renderer.load;
export const __preact_idle = renderer.idle;
export const __preact_visible = renderer.visible;

View file

@ -1,32 +0,0 @@
import type { ComponentRenderer } from '../../@types/renderer';
import React, { ComponentType } from 'react';
import ReactDOMServer from 'react-dom/server';
import { createRenderer } from './renderer';
import { childrenToVnodes } from './utils';
// This prevents tree-shaking of render.
Function.prototype(ReactDOMServer);
const ReactRenderer: ComponentRenderer<ComponentType> = {
jsxPragma: React.createElement,
jsxPragmaName: 'React.createElement',
renderStatic(Component) {
return async (props, ...children) => {
return ReactDOMServer.renderToString(React.createElement(Component, props, childrenToVnodes(React.createElement, children)));
};
},
imports: {
react: ['default: React'],
'react-dom': ['default: ReactDOM'],
},
render({ Component, root, children, props }) {
return `ReactDOM.hydrate(React.createElement(${Component}, ${props}, React.createElement(React.Fragment, null, ...${children})), ${root})`;
},
};
const renderer = createRenderer(ReactRenderer);
export const __react_static = renderer.static;
export const __react_load = renderer.load;
export const __react_idle = renderer.idle;
export const __react_visible = renderer.visible;

View file

@ -1,62 +0,0 @@
import type { DynamicRenderContext, DynamicRendererGenerator, SupportedComponentRenderer, StaticRendererGenerator } from '../../@types/renderer';
import { childrenToH } from './utils';
// This prevents tree-shaking of render.
Function.prototype(childrenToH);
/** Initialize Astro Component renderer for Static and Dynamic components */
export function createRenderer(renderer: SupportedComponentRenderer) {
const _static: StaticRendererGenerator = (Component) => renderer.renderStatic(Component);
const _imports = (context: DynamicRenderContext) => {
const values = Object.values(renderer.imports ?? {})
.reduce((acc, v) => {
return [...acc, `{ ${v.join(', ')} }`];
}, [])
.join(', ');
const libs = Object.keys(renderer.imports ?? {})
.reduce((acc: string[], lib: string) => {
return [...acc, `import("${context.frameworkUrls[lib as any]}")`];
}, [])
.join(',');
return `const [{${context.componentExport}: Component}, ${values}] = await Promise.all([import("${context.componentUrl}")${renderer.imports ? ', ' + libs : ''}]);`;
};
const serializeProps = ({ children: _, ...props }: Record<string, any>) => JSON.stringify(props);
const createContext = () => {
const astroId = `${Math.floor(Math.random() * 1e16)}`;
return { ['data-astro-id']: astroId, root: `document.querySelector('[data-astro-id="${astroId}"]')`, Component: 'Component' };
};
const createDynamicRender: DynamicRendererGenerator = (wrapperStart, wrapperEnd) => (Component, renderContext) => {
const innerContext = createContext();
return async (props, ...children) => {
let value: string;
try {
value = await _static(Component)(props, ...children);
} catch (e) {
value = '';
}
value = `<div data-astro-id="${innerContext['data-astro-id']}" style="display:contents">${value}</div>`;
const script = `${typeof wrapperStart === 'function' ? wrapperStart(innerContext) : wrapperStart}
${_imports(renderContext)}
${renderer.render({
...innerContext,
props: serializeProps(props),
children: `[${childrenToH(renderer, children) ?? ''}]`,
childrenAsString: `\`${children}\``,
})}
${typeof wrapperEnd === 'function' ? wrapperEnd(innerContext) : wrapperEnd}`;
return [value, `<script type="module">${script.trim()}</script>`].join('\n');
};
};
return {
static: _static,
load: createDynamicRender('(async () => {', '})()'),
idle: createDynamicRender('requestIdleCallback(async () => {', '})'),
visible: createDynamicRender(
'const o = new IntersectionObserver(async ([entry]) => { if (!entry.isIntersecting) { return; } o.disconnect();',
({ root }) => `}); Array.from(${root}.children).forEach(child => o.observe(child))`
),
};
}

View file

@ -1,27 +0,0 @@
import type { ComponentRenderer } from '../../@types/renderer';
import type { SvelteComponent } from 'svelte';
import { createRenderer } from './renderer';
import SvelteWrapper from '../SvelteWrapper.server.svelte';
const SvelteRenderer: ComponentRenderer<SvelteComponent> = {
renderStatic(Component) {
return async (props, ...children) => {
/// @ts-expect-error
const { html } = SvelteWrapper.render({ __astro_component: Component, __astro_children: children.join('\n'), ...props });
return html;
};
},
imports: {
'svelte-runtime': ['default: render'],
},
render({ Component, root, props, childrenAsString }) {
return `render(${root}, ${Component}, ${props}, ${childrenAsString});`;
},
};
const renderer = createRenderer(SvelteRenderer);
export const __svelte_static = renderer.static;
export const __svelte_load = renderer.load;
export const __svelte_idle = renderer.idle;
export const __svelte_visible = renderer.visible;

View file

@ -1,55 +0,0 @@
import unified from 'unified';
import parse from 'rehype-parse';
import toH from 'hast-to-hyperscript';
import { ComponentRenderer } from '../../@types/renderer';
import moize from 'moize';
/** @internal */
function childrenToTree(children: string[]): any[] {
return [].concat(...children.map((child) => (unified().use(parse, { fragment: true }).parse(child) as any).children));
}
/**
* Converts an HTML fragment string into vnodes for rendering via provided framework
* @param h framework's `createElement` function
* @param children the HTML string children
*/
export const childrenToVnodes = moize.deep(function childrenToVnodes(h: any, children: string[]) {
const tree = childrenToTree(children);
const vnodes = tree.map((subtree) => {
if (subtree.type === 'text') return subtree.value;
return toH(h, subtree);
});
return vnodes;
});
/**
* Converts an HTML fragment string into h function calls as a string
* @param h framework's `createElement` function
* @param children the HTML string children
*/
export const childrenToH = moize.deep(function childrenToH(renderer: ComponentRenderer<any>, children: string[]): any {
if (!renderer.jsxPragma) return;
const tree = childrenToTree(children);
const innerH = (name: any, attrs: Record<string, any> | null = null, _children: string[] | null = null) => {
const vnode = renderer.jsxPragma?.(name, attrs, _children);
const childStr = _children ? `, [${_children.map((child) => serializeChild(child)).join(',')}]` : '';
if (attrs && attrs.key) attrs.key = Math.random();
const __SERIALIZED = `${renderer.jsxPragmaName}("${name}", ${attrs ? JSON.stringify(attrs) : 'null'}${childStr})` as string;
return { ...vnode, __SERIALIZED };
};
const simpleTypes = new Set(['number', 'boolean']);
const serializeChild = (child: unknown) => {
if (typeof child === 'string') return JSON.stringify(child).replace(/<\/script>/gim, '</script" + ">');
if (simpleTypes.has(typeof child)) return JSON.stringify(child);
if (child === null) return `null`;
if ((child as any).__SERIALIZED) return (child as any).__SERIALIZED;
return innerH(child).__SERIALIZED;
};
return tree.map((subtree) => {
if (subtree.type === 'text') return JSON.stringify(subtree.value);
return toH(innerH, subtree).__SERIALIZED;
});
});

View file

@ -1,65 +0,0 @@
import type { ComponentRenderer } from '../../@types/renderer';
import type { Component as VueComponent } from 'vue';
import { renderToString } from '@vue/server-renderer';
import { defineComponent, createSSRApp, h as createElement } from 'vue';
import { createRenderer } from './renderer';
// This prevents tree-shaking of render.
Function.prototype(renderToString);
/**
* Users might attempt to use :vueAttribute syntax to pass primitive values.
* If so, try to JSON.parse them to get the primitives
*/
function cleanPropsForVue(obj: Record<string, any>) {
let cleaned = {} as any;
for (let [key, value] of Object.entries(obj)) {
if (key.startsWith(':')) {
key = key.slice(1);
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {}
}
}
cleaned[key] = value;
}
return cleaned;
}
const Vue: ComponentRenderer<VueComponent> = {
jsxPragma: createElement,
jsxPragmaName: 'createElement',
renderStatic(Component) {
return async (props, ...children) => {
const App = defineComponent({
components: {
Component,
},
data() {
return { props };
},
template: `<Component v-bind="props">${children.join('\n')}</Component>`,
});
const app = createSSRApp(App);
const html = await renderToString(app);
return html;
};
},
imports: {
vue: ['createApp', 'h: createElement'],
},
render({ Component, root, props, children }) {
const vueProps = cleanPropsForVue(JSON.parse(props));
return `const App = { render: () => createElement(${Component}, ${JSON.stringify(vueProps)}, { default: () => ${children} }) };
createApp(App).mount(${root});`;
},
};
const renderer = createRenderer(Vue);
export const __vue_static = renderer.static;
export const __vue_load = renderer.load;
export const __vue_idle = renderer.idle;
export const __vue_visible = renderer.visible;

View file

@ -0,0 +1,8 @@
export function check(Component: any) {
return Component.isAstroComponent;
}
export async function renderToStaticMarkup(Component: any, props: any, children: string) {
const html = await Component.__render(props, children);
return { html }
};

View file

@ -1,10 +0,0 @@
import SvelteWrapper from '../SvelteWrapper.client.svelte';
import type { SvelteComponent } from 'svelte';
export default (target: Element, component: SvelteComponent, props: any, children: string) => {
new SvelteWrapper({
target,
props: { __astro_component: component, __astro_children: children, ...props },
hydrate: true,
});
};

View file

@ -4,18 +4,16 @@ import type { CompileError } from 'astro-parser';
import type { LogOptions } from './logger';
import type { AstroConfig, CollectionResult, CollectionRSS, CreateCollection, Params, RuntimeMode } from './@types/astro';
import resolve from 'resolve';
import { existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { fileURLToPath, pathToFileURL } from 'url';
import { posix as path } from 'path';
import { performance } from 'perf_hooks';
import { loadConfiguration, logger as snowpackLogger, startServer as startSnowpackServer } from 'snowpack';
import { canonicalURL, stopTimer } from './build/util.js';
import { debug, info } from './logger.js';
import { searchForPage } from './search.js';
// We need to use require.resolve for snowpack plugins, so create a require function here.
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
interface RuntimeConfig {
astroConfig: AstroConfig;
logging: LogOptions;
@ -268,21 +266,28 @@ interface CreateSnowpackOptions {
resolvePackageUrl?: (pkgName: string) => Promise<string>;
}
const defaultRenderers = [
'@astro-renderer/vue',
'@astro-renderer/svelte',
'@astro-renderer/react',
'@astro-renderer/preact'
];
/** Create a new Snowpack instance to power Astro */
async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackOptions) {
const { projectRoot, astroRoot, extensions } = astroConfig;
const { projectRoot, astroRoot, renderers = defaultRenderers } = astroConfig;
const { env, mode, resolvePackageUrl } = options;
const internalPath = new URL('./frontend/', import.meta.url);
const resolveDependency = (dep: string) => resolve.sync(dep, { basedir: fileURLToPath(projectRoot) });
let snowpack: SnowpackDevServer;
const astroPlugOptions: {
let astroPluginOptions: {
resolvePackageUrl?: (s: string) => Promise<string>;
extensions?: Record<string, string>;
renderers?: { name: string, client: string, server: string }[];
astroConfig: AstroConfig;
} = {
astroConfig,
extensions,
resolvePackageUrl,
};
@ -300,22 +305,58 @@ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackO
(process.env as any).TAILWIND_DISABLE_TOUCH = true;
}
const rendererInstances = (await Promise.all(renderers.map(renderer => import(pathToFileURL(resolveDependency(renderer)).toString()))))
.map(({ default: raw }, i) => {
const { name = renderers[i], client, server, snowpackPlugin: snowpackPluginName, snowpackPluginOptions } = raw;
if (typeof client !== 'string') {
throw new Error(`Expected "client" from ${name} to be a relative path to the client-side renderer!`);
}
if (typeof server !== 'string') {
throw new Error(`Expected "server" from ${name} to be a relative path to the server-side renderer!`);
}
let snowpackPlugin: string|[string, any]|undefined;
if (typeof snowpackPluginName === 'string') {
if (snowpackPluginOptions) {
snowpackPlugin = [resolveDependency(snowpackPluginName), snowpackPluginOptions]
} else {
snowpackPlugin = resolveDependency(snowpackPluginName);
}
} else if (snowpackPluginName) {
throw new Error(`Expected the snowpackPlugin from ${name} to be a "string" but encountered "${typeof snowpackPluginName}"!`);
}
return {
name,
snowpackPlugin,
client: path.join(name, raw.client),
server: path.join(name, raw.server),
}
})
astroPluginOptions.renderers = rendererInstances;
// Make sure that Snowpack builds our renderer plugins
const knownEntrypoints = [].concat(...rendererInstances.map(renderer => [renderer.server, renderer.client]) as any) as string[];
const rendererSnowpackPlugins = rendererInstances.filter(renderer => renderer.snowpackPlugin).map(renderer => renderer.snowpackPlugin) as string|[string, any];
const snowpackConfig = await loadConfiguration({
root: fileURLToPath(projectRoot),
mount: mountOptions,
mode,
plugins: [
[fileURLToPath(new URL('../snowpack-plugin.cjs', import.meta.url)), astroPlugOptions],
[require.resolve('@snowpack/plugin-svelte'), { compilerOptions: { hydratable: true } }],
require.resolve('@snowpack/plugin-vue'),
require.resolve('@snowpack/plugin-sass'),
[fileURLToPath(new URL('../snowpack-plugin.cjs', import.meta.url)), astroPluginOptions],
...rendererSnowpackPlugins,
resolveDependency('@snowpack/plugin-sass'),
[
require.resolve('@snowpack/plugin-postcss'),
resolveDependency('@snowpack/plugin-postcss'),
{
config: {
plugins: {
[require.resolve('autoprefixer')]: {},
...(astroConfig.devOptions.tailwindConfig ? { [require.resolve('tailwindcss')]: {} } : {}),
[resolveDependency('autoprefixer')]: {},
...(astroConfig.devOptions.tailwindConfig ? { [resolveDependency('autoprefixer')]: {} } : {}),
},
},
},
@ -331,7 +372,7 @@ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackO
out: astroConfig.dist,
},
packageOptions: {
knownEntrypoints: ['preact-render-to-string'],
knownEntrypoints,
external: ['@vue/server-renderer', 'node-fetch', 'prismjs/components/index.js', 'gray-matter'],
},
});

View file

@ -10,7 +10,7 @@ setupBuild(ComponentChildren, './fixtures/astro-children');
ComponentChildren('Passes string children to framework components', async ({ runtime }) => {
let result = await runtime.load('/strings');
if (result.error) throw new Error(result.error);
if (result.error) throw new Error(result);
const $ = doc(result.contents);
@ -30,19 +30,22 @@ ComponentChildren('Passes markup children to framework components', async ({ run
const $ = doc(result.contents);
const $preact = $('#preact > h1');
const $preact = $('#preact h1');
assert.equal($preact.text().trim(), 'Hello world', 'Can pass markup to Preact components');
const $vue = $('#vue > h1');
const $vue = $('#vue h1');
assert.equal($vue.text().trim(), 'Hello world', 'Can pass markup to Vue components');
const $svelte = $('#svelte > h1');
const $svelte = $('#svelte h1');
assert.equal($svelte.text().trim(), 'Hello world', 'Can pass markup to Svelte components');
});
ComponentChildren('Passes multiple children to framework components', async ({ runtime }) => {
let result = await runtime.load('/multiple');
if (result.error) throw new Error(result.error);
if (result.error) {
console.log(result);
throw new Error(result.error);
}
const $ = doc(result.contents);

View file

@ -13,17 +13,17 @@ DynamicComponents('Loads client-only packages', async ({ runtime }) => {
// Grab the react-dom import
const exp = /import\("(.+?)"\)/g;
let match, reactDomURL;
let match, reactRenderer;
while ((match = exp.exec(result.contents))) {
if (match[1].includes('react-dom')) {
reactDomURL = match[1];
if (match[1].includes('renderers/react/client.js')) {
reactRenderer = match[1];
}
}
assert.ok(reactDomURL, 'React dom is on the page');
assert.ok(reactRenderer, 'React renderer is on the page');
result = await runtime.load(reactDomURL);
assert.equal(result.statusCode, 200, 'Can load react-dom');
result = await runtime.load(reactRenderer);
assert.equal(result.statusCode, 200, 'Can load react renderer');
});
DynamicComponents('Can be built', async ({ build }) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
import { h, hydrate } from 'preact';
import StaticHtml from './static-html.js';
export default (element) => (Component, props, children) => hydrate(h(Component, props, h(StaticHtml, { value: children })), element);

View file

@ -0,0 +1,5 @@
export default {
name: '@astro-renderer/preact',
client: './client',
server: './server',
}

View file

@ -0,0 +1,15 @@
{
"name": "@astro-renderer/preact",
"version": "0.0.1",
"type": "module",
"exports": {
".": "./index.js",
"./client": "./client.js",
"./server": "./server.js",
"./package.json": "./package.json"
},
"dependencies": {
"preact": "^10.5.13",
"preact-render-to-string": "^5.1.18"
}
}

View file

@ -0,0 +1,20 @@
import { h } from 'preact';
import { renderToString } from 'preact-render-to-string';
import StaticHtml from './static-html.js';
function check(Component, props) {
try {
return Boolean(renderToString(h(Component, props)));
} catch (e) {}
return false;
};
function renderToStaticMarkup(Component, props, children) {
const html = renderToString(h(Component, props, h(StaticHtml, { value: children })))
return { html };
}
export default {
check,
renderToStaticMarkup
}

View file

@ -0,0 +1,24 @@
import { h } from 'preact';
/**
* Astro passes `children` as a string of HTML, so we need
* a wrapper `div` to render that content as VNodes.
*
* As a bonus, we can signal to Preact that this subtree is
* entirely static and will never change via `shouldComponentUpdate`.
*/
const StaticHtml = ({ value }) => {
if (!value) return null;
return h('astro-fragment', { dangerouslySetInnerHTML: { __html: value }});
}
/**
* This tells Preact to opt-out of re-rendering this subtree,
* In addition to being a performance optimization,
* this also allows other frameworks to attach to `children`.
*
* See https://preactjs.com/guide/v8/external-dom-mutations
*/
StaticHtml.shouldComponentUpdate = () => false;
export default StaticHtml;

View file

@ -0,0 +1,5 @@
import { createElement } from 'react';
import { hydrate } from 'react-dom';
import StaticHtml from './static-html.js';
export default (element) => (Component, props, children) => hydrate(createElement(Component, { ...props, suppressHydrationWarning: true }, createElement(StaticHtml, { value: children, suppressHydrationWarning: true })), element);

View file

@ -0,0 +1,5 @@
export default {
name: '@astro-renderer/react',
client: './client',
server: './server',
}

View file

@ -0,0 +1,15 @@
{
"name": "@astro-renderer/react",
"version": "0.0.1",
"type": "module",
"exports": {
".": "./index.js",
"./client": "./client.js",
"./server": "./server.js",
"./package.json": "./package.json"
},
"dependencies": {
"react": "^17.0.1",
"react-dom": "^17.0.1"
}
}

View file

@ -0,0 +1,20 @@
import { createElement as h } from 'react';
import { renderToStaticMarkup as renderToString } from 'react-dom/server.js';
import StaticHtml from './static-html.js';
function check(Component, props) {
try {
return Boolean(renderToString(h(Component, props)));
} catch (e) {}
return false;
};
function renderToStaticMarkup(Component, props, children) {
const html = renderToString(h(Component, props, h(StaticHtml, { value: children })))
return { html };
}
export default {
check,
renderToStaticMarkup
};

View file

@ -0,0 +1,24 @@
import { createElement as h } from 'react';
/**
* Astro passes `children` as a string of HTML, so we need
* a wrapper `div` to render that content as VNodes.
*
* As a bonus, we can signal to React that this subtree is
* entirely static and will never change via `shouldComponentUpdate`.
*/
const StaticHtml = ({ value }) => {
if (!value) return null;
return h('astro-fragment', { suppressHydrationWarning: true, dangerouslySetInnerHTML: { __html: value }});
}
/**
* This tells React to opt-out of re-rendering this subtree,
* In addition to being a performance optimization,
* this also allows other frameworks to attach to `children`.
*
* See https://preactjs.com/guide/v8/external-dom-mutations
*/
StaticHtml.shouldComponentUpdate = () => false;
export default StaticHtml;

View file

@ -0,0 +1,21 @@
<script>
/**
* Why do we need a wrapper component?
*
* Astro passes `children` as a string of HTML, so we need
* a way to render that content.
*
* Rather than passing a magical prop which needs special
* handling, using this wrapper allows Svelte users to just
* use `<slot />` like they would for any other component.
*/
const { __astro_component: Component, __astro_children, ...props } = $$props;
</script>
<svelte:component this={Component} {...props}>
{#if __astro_children}
<astro-fragment>
{@html __astro_children}
</astro-fragment>
{/if}
</svelte:component>

View file

@ -0,0 +1,13 @@
import SvelteWrapper from './Wrapper.svelte';
export default (target) => {
return (component, props, children) => {
try {
new SvelteWrapper({
target,
props: { __astro_component: component, __astro_children: children, ...props },
hydrate: true,
});
} catch (e) {}
}
}

View file

@ -0,0 +1,7 @@
export default {
name: '@astro-renderer/svelte',
snowpackPlugin: '@snowpack/plugin-svelte',
snowpackPluginOptions: { compilerOptions: { hydratable: true } },
client: './client',
server: './server'
};

View file

@ -0,0 +1,15 @@
{
"name": "@astro-renderer/svelte",
"version": "0.0.1",
"type": "module",
"exports": {
".": "./index.js",
"./client": "./client.js",
"./server": "./server.js",
"./package.json": "./package.json"
},
"dependencies": {
"svelte": "^3.35.0",
"@snowpack/plugin-svelte": "^3.7.0"
}
}

View file

@ -0,0 +1,15 @@
import SvelteWrapper from './Wrapper.svelte';
function check(Component) {
return Component['render'] && Component['$$render'];
}
async function renderToStaticMarkup (Component, props, children) {
const { html } = SvelteWrapper.render({ __astro_component: Component, __astro_children: children, ...props });
return { html };
}
export default {
check,
renderToStaticMarkup
};

View file

@ -0,0 +1,7 @@
import { h, createSSRApp } from 'vue';
import StaticHtml from './static-html.js';
export default (element) => (Component, props, children) => {
const app = createSSRApp({ render: () => h(Component, props, { default: () => h(StaticHtml, { value: children }) })});
app.mount(element, true);
};

View file

@ -0,0 +1,6 @@
export default {
name: '@astro-renderer/vue',
snowpackPlugin: '@snowpack/plugin-vue',
client: './client',
server: './server'
};

View file

@ -0,0 +1,16 @@
{
"name": "@astro-renderer/vue",
"version": "0.0.1",
"type": "module",
"exports": {
".": "./index.js",
"./client": "./client.js",
"./server": "./server.js",
"./package.json": "./package.json"
},
"dependencies": {
"vue": "^3.0.10",
"@vue/server-renderer": "^3.0.10",
"@snowpack/plugin-vue": "^2.5.0"
}
}

View file

@ -0,0 +1,18 @@
import { renderToString } from '@vue/server-renderer';
import { h, createSSRApp } from 'vue';
import StaticHtml from './static-html.js';
function check(Component) {
return Component['ssrRender'];
}
async function renderToStaticMarkup(Component, props, children) {
const app = createSSRApp({ render: () => h(Component, props, { default: () => h(StaticHtml, { value: children }) })});
const html = await renderToString(app);
return { html };
}
export default {
check,
renderToStaticMarkup
}

View file

@ -0,0 +1,27 @@
import { h, defineComponent } from 'vue';
/**
* Astro passes `children` as a string of HTML, so we need
* a wrapper `div` to render that content as VNodes.
*
* This is the Vue + JSX equivalent of using `<div v-html="value" />`
*/
const StaticHtml = defineComponent({
props: {
value: String
},
setup({ value }) {
if (!value) return () => null;
return () => h('astro-fragment', { innerHTML: value })
}
})
/**
* Other frameworks have `shouldComponentUpdate` in order to signal
* that this subtree is entirely static and will not be updated
*
* Fortunately, Vue is smart enough to figure that out without any
* help from us, so this just works out of the box!
*/
export default StaticHtml;

View file

@ -477,7 +477,7 @@
]
},
{
"begin": "(</?)([A-Z][a-zA-Z0-9-]*)(\\:(load|idle|visible))?",
"begin": "(</?)([A-Z][a-zA-Z0-9-\\.]*|[a-z]+\\.[a-zA-Z0-9-]+)(\\:(load|idle|visible))?",
"beginCaptures": {
"1": {
"name": "punctuation.definition.tag.begin.html"

3297
yarn.lock

File diff suppressed because it is too large Load diff