diff options
Diffstat (limited to 'packages/astro/src')
25 files changed, 384 insertions, 752 deletions
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 2f9983f53..ddd08b726 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -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>; diff --git a/packages/astro/src/@types/compiler.ts b/packages/astro/src/@types/compiler.ts index a661f1622..10703c523 100644 --- a/packages/astro/src/@types/compiler.ts +++ b/packages/astro/src/@types/compiler.ts @@ -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; } diff --git a/packages/astro/src/@types/hydrate.ts b/packages/astro/src/@types/hydrate.ts new file mode 100644 index 000000000..1f89b6464 --- /dev/null +++ b/packages/astro/src/@types/hydrate.ts @@ -0,0 +1 @@ +export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: string|null) => void>; diff --git a/packages/astro/src/@types/resolve.d.ts b/packages/astro/src/@types/resolve.d.ts new file mode 100644 index 000000000..a4cc7d062 --- /dev/null +++ b/packages/astro/src/@types/resolve.d.ts @@ -0,0 +1 @@ +declare module 'resolve'; diff --git a/packages/astro/src/build.ts b/packages/astro/src/build.ts index be9ec8c7b..208072390 100644 --- a/packages/astro/src/build.ts +++ b/packages/astro/src/build.ts @@ -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); + } } }); diff --git a/packages/astro/src/build/page.ts b/packages/astro/src/build/page.ts index a83a945d3..b53b5f5bc 100644 --- a/packages/astro/src/build/page.ts +++ b/packages/astro/src/build/page.ts @@ -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; // don’t 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; + ]); } diff --git a/packages/astro/src/compiler/codegen/index.ts b/packages/astro/src/compiler/codegen/index.ts index 8535681ea..25243a79c 100644 --- a/packages/astro/src/compiler/codegen/index.ts +++ b/packages/astro/src/compiler/codegen/index.ts @@ -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}`); - } - 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')}';`, - }; - } - - 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')}';`, - }; + 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: `__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')}';`, - }; + case 'ImportNamespaceSpecifier': { + const [_, value] = name.split('.'); + return { value }; } - - 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); diff --git a/packages/astro/src/compiler/index.ts b/packages/astro/src/compiler/index.ts index a03e9c2ae..ac107ae7a 100644 --- a/packages/astro/src/compiler/index.ts +++ b/packages/astro/src/compiler/index.ts @@ -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, diff --git a/packages/astro/src/compiler/transform/hydration.ts b/packages/astro/src/compiler/transform/hydration.ts new file mode 100644 index 000000000..23b0fd4ba --- /dev/null +++ b/packages/astro/src/compiler/transform/hydration.ts @@ -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]; + }, + }; +} diff --git a/packages/astro/src/compiler/transform/index.ts b/packages/astro/src/compiler/transform/index.ts index 27f5a3212..d622846d9 100644 --- a/packages/astro/src/compiler/transform/index.ts +++ b/packages/astro/src/compiler/transform/index.ts @@ -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); diff --git a/packages/astro/src/compiler/utils.ts b/packages/astro/src/compiler/utils.ts index 5275a42a7..2b7438d87 100644 --- a/packages/astro/src/compiler/utils.ts +++ b/packages/astro/src/compiler/utils.ts @@ -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); +} diff --git a/packages/astro/src/frontend/SvelteWrapper.svelte b/packages/astro/src/frontend/SvelteWrapper.svelte deleted file mode 100644 index c3e926128..000000000 --- a/packages/astro/src/frontend/SvelteWrapper.svelte +++ /dev/null @@ -1,7 +0,0 @@ -<script> -const { __astro_component: Component, __astro_children, ...props } = $$props; -</script> - -<svelte:component this={Component} {...props}> - {@html __astro_children} -</svelte:component> diff --git a/packages/astro/src/frontend/__astro_component.ts b/packages/astro/src/frontend/__astro_component.ts new file mode 100644 index 000000000..dca4e45f2 --- /dev/null +++ b/packages/astro/src/frontend/__astro_component.ts @@ -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'); + } +} diff --git a/packages/astro/src/frontend/hydrate/idle.ts b/packages/astro/src/frontend/hydrate/idle.ts new file mode 100644 index 000000000..2fd96b9cb --- /dev/null +++ b/packages/astro/src/frontend/hydrate/idle.ts @@ -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); + } +} diff --git a/packages/astro/src/frontend/hydrate/load.ts b/packages/astro/src/frontend/hydrate/load.ts new file mode 100644 index 000000000..38ac1a0ea --- /dev/null +++ b/packages/astro/src/frontend/hydrate/load.ts @@ -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); + } +} diff --git a/packages/astro/src/frontend/hydrate/visible.ts b/packages/astro/src/frontend/hydrate/visible.ts new file mode 100644 index 000000000..d4dacdf51 --- /dev/null +++ b/packages/astro/src/frontend/hydrate/visible.ts @@ -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); + } + } +} diff --git a/packages/astro/src/frontend/render/preact.ts b/packages/astro/src/frontend/render/preact.ts deleted file mode 100644 index 5c50b6fe3..000000000 --- a/packages/astro/src/frontend/render/preact.ts +++ /dev/null @@ -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; diff --git a/packages/astro/src/frontend/render/react.ts b/packages/astro/src/frontend/render/react.ts deleted file mode 100644 index 063c6a2b5..000000000 --- a/packages/astro/src/frontend/render/react.ts +++ /dev/null @@ -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; diff --git a/packages/astro/src/frontend/render/renderer.ts b/packages/astro/src/frontend/render/renderer.ts deleted file mode 100644 index a7f6d165d..000000000 --- a/packages/astro/src/frontend/render/renderer.ts +++ /dev/null @@ -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))` - ), - }; -} diff --git a/packages/astro/src/frontend/render/svelte.ts b/packages/astro/src/frontend/render/svelte.ts deleted file mode 100644 index 69c7ac8b4..000000000 --- a/packages/astro/src/frontend/render/svelte.ts +++ /dev/null @@ -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; diff --git a/packages/astro/src/frontend/render/utils.ts b/packages/astro/src/frontend/render/utils.ts deleted file mode 100644 index 64a712561..000000000 --- a/packages/astro/src/frontend/render/utils.ts +++ /dev/null @@ -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; - }); -}); diff --git a/packages/astro/src/frontend/render/vue.ts b/packages/astro/src/frontend/render/vue.ts deleted file mode 100644 index 57c3c8276..000000000 --- a/packages/astro/src/frontend/render/vue.ts +++ /dev/null @@ -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; diff --git a/packages/astro/src/frontend/renderer-astro.ts b/packages/astro/src/frontend/renderer-astro.ts new file mode 100644 index 000000000..36b74ff8f --- /dev/null +++ b/packages/astro/src/frontend/renderer-astro.ts @@ -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 } +}; diff --git a/packages/astro/src/frontend/runtime/svelte.ts b/packages/astro/src/frontend/runtime/svelte.ts deleted file mode 100644 index 7a41586b4..000000000 --- a/packages/astro/src/frontend/runtime/svelte.ts +++ /dev/null @@ -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, - }); -}; diff --git a/packages/astro/src/runtime.ts b/packages/astro/src/runtime.ts index 849642002..bc5762585 100644 --- a/packages/astro/src/runtime.ts +++ b/packages/astro/src/runtime.ts @@ -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'], }, }); |