From 491ff66603119928963fd58154a4a77246f342ca Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 21 Jun 2021 08:44:45 -0400 Subject: [PATCH] Allow renderers configuration to update (#489) * Start of dynamic renderers * Implementation --- packages/astro/snowpack-plugin.cjs | 61 ++++---- packages/astro/src/@types/astro.ts | 13 ++ packages/astro/src/config_manager.ts | 140 ++++++++++++++++++ packages/astro/src/frontend/__astro_config.ts | 6 + .../astro/src/internal/__astro_component.ts | 28 ++-- packages/astro/src/runtime.ts | 68 +++------ 6 files changed, 218 insertions(+), 98 deletions(-) create mode 100644 packages/astro/src/config_manager.ts create mode 100644 packages/astro/src/frontend/__astro_config.ts diff --git a/packages/astro/snowpack-plugin.cjs b/packages/astro/snowpack-plugin.cjs index 47d784975..a50816089 100644 --- a/packages/astro/snowpack-plugin.cjs +++ b/packages/astro/snowpack-plugin.cjs @@ -4,8 +4,22 @@ const transformPromise = import('./dist/compiler/index.js'); const DEFAULT_HMR_PORT = 12321; -/** @type {import('snowpack').SnowpackPluginFactory} */ -module.exports = (snowpackConfig, { resolvePackageUrl, renderers, astroConfig, mode } = {}) => { +/** + * @typedef {Object} PluginOptions - creates a new type named 'SpecialType' + * @prop {import('./src/config_manager').ConfigManager} configManager + * @prop {'development' | 'production'} mode + */ + +/** + * @type {import('snowpack').SnowpackPluginFactory} + */ +module.exports = (snowpackConfig, options = {}) => { + const { + resolvePackageUrl, + astroConfig, + configManager, + mode + } = options; let hmrPort = DEFAULT_HMR_PORT; return { name: 'snowpack-astro', @@ -14,36 +28,18 @@ module.exports = (snowpackConfig, { resolvePackageUrl, renderers, astroConfig, m 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 = renderers.map(({ server }) => 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; + if(configManager.isConfigModule(fileExt, id)) { + configManager.configModuleId = id; + const source = await configManager.buildSource(contents); + return source; + } + }, + onChange({ filePath }) { + // If the astro.config.mjs file changes, mark the generated config module as changed. + if(configManager.isAstroConfig(filePath) && configManager.configModuleId) { + this.markChanged(configManager.configModuleId); + configManager.markDirty(); } }, config(snowpackConfig) { @@ -55,12 +51,13 @@ ${contents}`; const { compileComponent } = await transformPromise; const projectRoot = snowpackConfig.root; const contents = await readFile(filePath, 'utf-8'); + + /** @type {import('./src/@types/compiler').CompileOptions} */ const compileOptions = { astroConfig, hmrPort, mode, resolvePackageUrl, - renderers, }; const result = await compileComponent(contents, { compileOptions, filename: filePath, projectRoot }); const output = { diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index db0b77589..f76f0c96f 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -176,3 +176,16 @@ export interface ComponentInfo { } export type Components = Map; + +type AsyncRendererComponentFn = ( + Component: any, + props: any, + children: string | undefined +) => Promise; + +export interface Renderer { + check: AsyncRendererComponentFn; + renderToStaticMarkup: AsyncRendererComponentFn<{ + html: string; + }>; +} \ No newline at end of file diff --git a/packages/astro/src/config_manager.ts b/packages/astro/src/config_manager.ts new file mode 100644 index 000000000..808ed4246 --- /dev/null +++ b/packages/astro/src/config_manager.ts @@ -0,0 +1,140 @@ +import type { ServerRuntime as SnowpackServerRuntime } from 'snowpack'; +import type { AstroConfig } from './@types/astro'; +import { posix as path } from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; +import resolve from 'resolve'; +import { loadConfig } from './config.js'; + +type RendererSnowpackPlugin = string | [string, any] | undefined; + +interface RendererInstance { + name: string; + snowpackPlugin: RendererSnowpackPlugin; + client: string; + server: string; +} + +const CONFIG_MODULE_BASE_NAME = '__astro_config.js'; +const CONFIG_MODULE_URL = `/_astro_frontend/${CONFIG_MODULE_BASE_NAME}`; + +const DEFAULT_RENDERERS = [ + '@astrojs/renderer-vue', + '@astrojs/renderer-svelte', + '@astrojs/renderer-react', + '@astrojs/renderer-preact' +]; + +export class ConfigManager { + private state: 'initial' | 'dirty' | 'clean' = 'initial'; + public snowpackRuntime: SnowpackServerRuntime | null = null; + public configModuleId: string | null = null; + private rendererNames!: string[]; + private version = 1; + + constructor( + private astroConfig: AstroConfig, + private resolvePackageUrl: (pkgName: string) => Promise, + ) { + this.setRendererNames(this.astroConfig); + } + + markDirty() { + this.state = 'dirty'; + } + + async update() { + if(this.needsUpdate() && this.snowpackRuntime) { + // astro.config.mjs has changed, reload it. + if(this.state === 'dirty') { + const version = this.version++; + const astroConfig = await loadConfig(this.astroConfig.projectRoot.pathname, `astro.config.mjs?version=${version}`); + this.setRendererNames(astroConfig); + } + + await this.importModule(this.snowpackRuntime); + this.state = 'clean'; + } + } + + isConfigModule(fileExt: string, filename: string) { + return fileExt === '.js' && filename.endsWith(CONFIG_MODULE_BASE_NAME); + } + + isAstroConfig(filename: string) { + const { projectRoot } = this.astroConfig; + return new URL('./astro.config.mjs', projectRoot).pathname === filename; + } + + async buildRendererInstances(): Promise { + const { projectRoot } = this.astroConfig; + const rendererNames = this.rendererNames; + const resolveDependency = (dep: string) => resolve.sync(dep, { basedir: fileURLToPath(projectRoot) }); + + const rendererInstances = ( + await Promise.all( + rendererNames.map((rendererName) => { + const entrypoint = pathToFileURL(resolveDependency(rendererName)).toString(); + return import(entrypoint); + }) + ) + ).map(({ default: raw }, i) => { + const { name = rendererNames[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: RendererSnowpackPlugin; + 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), + }; + }); + + return rendererInstances; + } + + async buildSource(contents: string): Promise { + const renderers = await this.buildRendererInstances(); + const rendererServerPackages = renderers.map(({ server }) => server); + const rendererClientPackages = await Promise.all(renderers.map(({ client }) => this.resolvePackageUrl(client))); + const result = /* js */ `${rendererServerPackages.map((pkg, i) => `import __renderer_${i} from "${pkg}";`).join('\n')} + +import { setRenderers } from 'astro/dist/internal/__astro_component.js'; + +let rendererSources = [${rendererClientPackages.map(pkg => `"${pkg}"`).join(', ')}]; +let renderers = [${rendererServerPackages.map((_, i) => `__renderer_${i}`).join(', ')}]; + +${contents} +`; + return result; + } + + needsUpdate(): boolean { + return this.state === 'initial' || this.state === 'dirty'; + } + + private setRendererNames(astroConfig: AstroConfig) { + this.rendererNames = astroConfig.renderers || DEFAULT_RENDERERS; + } + + private async importModule(snowpackRuntime: SnowpackServerRuntime): Promise { + await snowpackRuntime!.importModule(CONFIG_MODULE_URL); + } +} \ No newline at end of file diff --git a/packages/astro/src/frontend/__astro_config.ts b/packages/astro/src/frontend/__astro_config.ts new file mode 100644 index 000000000..1765ffffc --- /dev/null +++ b/packages/astro/src/frontend/__astro_config.ts @@ -0,0 +1,6 @@ +declare function setRenderers(sources: string[], renderers: any[]): void; + +declare let rendererSources: string[]; +declare let renderers: any[]; + +setRenderers(rendererSources, renderers); \ No newline at end of file diff --git a/packages/astro/src/internal/__astro_component.ts b/packages/astro/src/internal/__astro_component.ts index 4976fe84f..1e0a75c16 100644 --- a/packages/astro/src/internal/__astro_component.ts +++ b/packages/astro/src/internal/__astro_component.ts @@ -1,3 +1,4 @@ +import type { Renderer } from '../@types/astro'; import hash from 'shorthash'; import { valueToEstree, Value } from 'estree-util-value-to-estree'; import { generate } from 'astring'; @@ -7,22 +8,13 @@ import * as astro from './renderer-astro'; // 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[]; +let rendererSources: string[] = []; +let renderers: Renderer[] = []; -__rendererSources = ['', ...__rendererSources]; -__renderers = [astro, ...__renderers]; +export function setRenderers(_rendererSources: string[], _renderers: Renderer[]) { + rendererSources = [''].concat(_rendererSources); + renderers = [astro as Renderer].concat(_renderers); +} const rendererCache = new WeakMap(); @@ -33,7 +25,7 @@ async function resolveRenderer(Component: any, props: any = {}, children?: strin } const errors: Error[] = []; - for (const __renderer of __renderers) { + for (const __renderer of renderers) { // Yes, we do want to `await` inside of this loop! // __renderer.check can't be run in parallel, it // returns the first match and skips any subsequent checks @@ -64,7 +56,7 @@ interface AstroComponentProps { /** For hydrated components, generate a