diff --git a/.changeset/tall-eyes-vanish.md b/.changeset/tall-eyes-vanish.md new file mode 100644 index 000000000000..06b7f62696a2 --- /dev/null +++ b/.changeset/tall-eyes-vanish.md @@ -0,0 +1,51 @@ +--- +'astro': minor +--- + +Integrations can add new `client:` directives through the `astro:config:setup` hook's `addClientDirective()` API. To enable this API, the user needs to set `experimental.customClientDirectives` to `true` in their config. + +```js +import { defineConfig } from 'astro/config'; +import onClickDirective from 'astro-click-directive'; + +export default defineConfig({ + integrations: [onClickDirective()], + experimental: { + customClientDirectives: true + } +}); +``` + +```js +export default function onClickDirective() { + return { + hooks: { + 'astro:config:setup': ({ addClientDirective }) => { + addClientDirective({ + name: 'click', + entrypoint: 'astro-click-directive/click.js' + }); + }, + } + } +} +``` + +```astro +<Counter client:click /> +``` + +The client directive file (e.g. `astro-click-directive/click.js`) should export a function of type `ClientDirective`: + +```ts +import type { ClientDirective } from 'astro' + +const clickDirective: ClientDirective = (load, opts, el) => { + window.addEventListener('click', async () => { + const hydrate = await load() + await hydrate() + }, { once: true }) +} + +export default clickDirective +``` diff --git a/packages/astro/astro-jsx.d.ts b/packages/astro/astro-jsx.d.ts index 9f89b8dd066d..c3b28b36a06a 100644 --- a/packages/astro/astro-jsx.d.ts +++ b/packages/astro/astro-jsx.d.ts @@ -18,12 +18,13 @@ declare namespace astroHTML.JSX { children: {}; } - interface IntrinsicAttributes extends AstroBuiltinProps, AstroBuiltinAttributes { + interface IntrinsicAttributes extends AstroBuiltinProps, AstroBuiltinAttributes, AstroClientDirectives { slot?: string; children?: Children; } type AstroBuiltinProps = import('./dist/@types/astro.js').AstroBuiltinProps; + type AstroClientDirectives = import('./dist/@types/astro.js').AstroClientDirectives; type AstroBuiltinAttributes = import('./dist/@types/astro.js').AstroBuiltinAttributes; type AstroDefineVarsAttribute = import('./dist/@types/astro.js').AstroDefineVarsAttribute; type AstroScriptAttributes = import('./dist/@types/astro.js').AstroScriptAttributes & diff --git a/packages/astro/e2e/custom-client-directives.test.js b/packages/astro/e2e/custom-client-directives.test.js new file mode 100644 index 000000000000..fec5ef9a1104 --- /dev/null +++ b/packages/astro/e2e/custom-client-directives.test.js @@ -0,0 +1,92 @@ +import { expect } from '@playwright/test'; +import { testFactory, waitForHydrate } from './test-utils.js'; +import testAdapter from '../test/test-adapter.js'; + +const test = testFactory({ + root: './fixtures/custom-client-directives/', +}); + +test.describe('Custom Client Directives - dev', () => { + let devServer; + + test.beforeAll(async ({ astro }) => { + devServer = await astro.startDevServer(); + }); + + test.afterAll(async () => { + await devServer.stop(); + }); + + testClientDirectivesShared(); +}); + +test.describe('Custom Client Directives - build static', () => { + let previewServer; + + test.beforeAll(async ({ astro }) => { + await astro.build(); + previewServer = await astro.preview(); + }); + + test.afterAll(async () => { + await previewServer.stop(); + }); + + testClientDirectivesShared(); +}); + +test.describe('Custom Client Directives - build server', () => { + let previewServer; + + test.beforeAll(async ({ astro }) => { + await astro.build({ + adapter: testAdapter(), + }); + previewServer = await astro.preview(); + }); + + test.afterAll(async () => { + await previewServer.stop(); + }); + + testClientDirectivesShared(); +}); + +function testClientDirectivesShared() { + test('client:click should work', async ({ astro, page }) => { + await page.goto(astro.resolveUrl('/')); + + const incrementBtn = page.locator('#client-click .increment'); + const counterValue = page.locator('#client-click pre'); + + await expect(counterValue).toHaveText('0'); + + // Component only hydrates on first click + await Promise.all([waitForHydrate(page, counterValue), incrementBtn.click()]); + + // Since first click only triggers hydration, this should stay 0 + await expect(counterValue).toHaveText('0'); + await incrementBtn.click(); + // Hydrated, this should be 1 + await expect(counterValue).toHaveText('1'); + }); + + test('client:password should work', async ({ astro, page }) => { + await page.goto(astro.resolveUrl('/')); + + const incrementBtn = page.locator('#client-password .increment'); + const counterValue = page.locator('#client-password pre'); + + await expect(counterValue).toHaveText('0'); + await incrementBtn.click(); + // Not hydrated, so this should stay 0 + await expect(counterValue).toHaveText('0'); + + // Type super cool password to activate password! + await Promise.all([waitForHydrate(page, counterValue), page.keyboard.type('hunter2')]); + + await incrementBtn.click(); + // Hydrated, this should be 1 + await expect(counterValue).toHaveText('1'); + }); +} diff --git a/packages/astro/e2e/fixtures/custom-client-directives/astro.config.mjs b/packages/astro/e2e/fixtures/custom-client-directives/astro.config.mjs new file mode 100644 index 000000000000..451c7ddd8ad3 --- /dev/null +++ b/packages/astro/e2e/fixtures/custom-client-directives/astro.config.mjs @@ -0,0 +1,38 @@ +import { defineConfig } from 'astro/config'; +import react from "@astrojs/react"; +import { fileURLToPath } from 'url'; + +export default defineConfig({ + integrations: [astroClientClickDirective(), astroClientPasswordDirective(), react()], + experimental: { + customClientDirectives: true + } +}); + +function astroClientClickDirective() { + return { + name: 'astro-client-click', + hooks: { + 'astro:config:setup': (opts) => { + opts.addClientDirective({ + name: 'click', + entrypoint: fileURLToPath(new URL('./client-click.js', import.meta.url)) + }); + } + } + }; +} + +function astroClientPasswordDirective() { + return { + name: 'astro-client-click', + hooks: { + 'astro:config:setup': (opts) => { + opts.addClientDirective({ + name: 'password', + entrypoint: fileURLToPath(new URL('./client-password.js', import.meta.url)) + }); + } + } + }; +} diff --git a/packages/astro/e2e/fixtures/custom-client-directives/client-click.js b/packages/astro/e2e/fixtures/custom-client-directives/client-click.js new file mode 100644 index 000000000000..a2866be78258 --- /dev/null +++ b/packages/astro/e2e/fixtures/custom-client-directives/client-click.js @@ -0,0 +1,7 @@ +// Hydrate on first click on the window +export default (load) => { + window.addEventListener('click', async () => { + const hydrate = await load() + await hydrate() + }, { once: true }) +} diff --git a/packages/astro/e2e/fixtures/custom-client-directives/client-password.js b/packages/astro/e2e/fixtures/custom-client-directives/client-password.js new file mode 100644 index 000000000000..36a4939340f7 --- /dev/null +++ b/packages/astro/e2e/fixtures/custom-client-directives/client-password.js @@ -0,0 +1,21 @@ +// Hydrate when the user types the correct password +export default (load, options) => { + const password = options.value + let consecutiveMatch = 0 + + const handleKeydown = async (e) => { + if (e.key === password[consecutiveMatch]) { + consecutiveMatch++ + } else { + consecutiveMatch = 0 + } + + if (consecutiveMatch === password.length) { + window.removeEventListener('keydown', handleKeydown) + const hydrate = await load() + await hydrate() + } + } + + window.addEventListener('keydown', handleKeydown) +} diff --git a/packages/astro/e2e/fixtures/custom-client-directives/package.json b/packages/astro/e2e/fixtures/custom-client-directives/package.json new file mode 100644 index 000000000000..ee1d8ec533c6 --- /dev/null +++ b/packages/astro/e2e/fixtures/custom-client-directives/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/custom-client-directives", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/react": "workspace:*", + "astro": "workspace:*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +} diff --git a/packages/astro/e2e/fixtures/custom-client-directives/src/client-directives-types.d.ts b/packages/astro/e2e/fixtures/custom-client-directives/src/client-directives-types.d.ts new file mode 100644 index 000000000000..07399f7bb09c --- /dev/null +++ b/packages/astro/e2e/fixtures/custom-client-directives/src/client-directives-types.d.ts @@ -0,0 +1,9 @@ +declare module 'astro' { + interface AstroClientDirectives { + 'client:click'?: boolean + 'client:password'?: string + } +} + +// Make d.ts a module to similate common packaging setups where the entry `index.d.ts` would augment the types +export {} diff --git a/packages/astro/e2e/fixtures/custom-client-directives/src/components/Counter.jsx b/packages/astro/e2e/fixtures/custom-client-directives/src/components/Counter.jsx new file mode 100644 index 000000000000..9d2212b0cae8 --- /dev/null +++ b/packages/astro/e2e/fixtures/custom-client-directives/src/components/Counter.jsx @@ -0,0 +1,18 @@ +import React, { useState } from 'react'; + +export default function Counter({ children, count: initialCount = 0, id }) { + const [count, setCount] = useState(initialCount); + const add = () => setCount((i) => i + 1); + const subtract = () => setCount((i) => i - 1); + + return ( + <> + <div id={id} className="counter"> + <button className="decrement" onClick={subtract}>-</button> + <pre>{count}</pre> + <button className="increment" onClick={add}>+</button> + </div> + <div className="counter-message">{children}</div> + </> + ); +} diff --git a/packages/astro/e2e/fixtures/custom-client-directives/src/pages/index.astro b/packages/astro/e2e/fixtures/custom-client-directives/src/pages/index.astro new file mode 100644 index 000000000000..05c28b109e1c --- /dev/null +++ b/packages/astro/e2e/fixtures/custom-client-directives/src/pages/index.astro @@ -0,0 +1,10 @@ +--- +import Counter from '../components/Counter.jsx'; +--- + +<html> +<body> + <Counter id="client-click" client:click>client:click</Counter> + <Counter id="client-password" client:password="hunter2">client:password</Counter> +</body> +</html> \ No newline at end of file diff --git a/packages/astro/e2e/fixtures/custom-client-directives/tsconfig.json b/packages/astro/e2e/fixtures/custom-client-directives/tsconfig.json new file mode 100644 index 000000000000..59a562e0e58b --- /dev/null +++ b/packages/astro/e2e/fixtures/custom-client-directives/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + // preserveSymlinks set to true so the augmented `declare module 'astro'` works. + // This is only needed because we link Astro locally. + "preserveSymlinks": true + }, + "include": ["./src/**/*"] +} \ No newline at end of file diff --git a/packages/astro/e2e/test-utils.js b/packages/astro/e2e/test-utils.js index 88daa8eec282..a11ba868b625 100644 --- a/packages/astro/e2e/test-utils.js +++ b/packages/astro/e2e/test-utils.js @@ -55,7 +55,6 @@ export async function getErrorOverlayContent(page) { } /** - * @param {import('@playwright/test').Locator} el * @returns {Promise<string>} */ export async function getColor(el) { diff --git a/packages/astro/package.json b/packages/astro/package.json index 5fd28d282eb6..56d9ed1028e6 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -137,6 +137,7 @@ "devalue": "^4.2.0", "diff": "^5.1.0", "es-module-lexer": "^1.1.0", + "esbuild": "^0.17.18", "estree-walker": "3.0.0", "execa": "^6.1.0", "fast-glob": "^3.2.11", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 0f8cf424017f..e20e0e5a8320 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -55,6 +55,10 @@ export interface AstroBuiltinProps { 'client:only'?: boolean | string; } +// Allow users to extend this for astro-jsx.d.ts +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AstroClientDirectives {} + export interface AstroBuiltinAttributes { 'class:list'?: | Record<string, boolean> @@ -1075,6 +1079,28 @@ export interface AstroUserConfig { */ inlineStylesheets?: 'always' | 'auto' | 'never'; + /** + * @docs + * @name experimental.customClientDirectives + * @type {boolean} + * @default `false` + * @version 2.5.0 + * @description + * Allow integrations to use the [experimental `addClientDirective` API](/en/reference/integrations-reference/#addclientdirective-option) in the `astro:config:setup` hook + * to add custom client directives in Astro files. + * + * To enable this feature, set `experimental.customClientDirectives` to `true` in your Astro config: + * + * ```js + * { + * experimental: { + * customClientDirectives: true, + * }, + * } + * ``` + */ + customClientDirectives?: boolean; + /** * @docs * @name experimental.middleware @@ -1206,6 +1232,10 @@ export interface AstroSettings { stage: InjectedScriptStage; content: string; }[]; + /** + * Map of directive name (e.g. `load`) to the directive script code + */ + clientDirectives: Map<string, string>; tsConfig: TsConfigJson | undefined; tsConfigPath: string | undefined; watchFiles: string[]; @@ -1654,6 +1684,7 @@ export interface AstroIntegration { addWatchFile: (path: URL | string) => void; injectScript: (stage: InjectedScriptStage, content: string) => void; injectRoute: (injectRoute: InjectedRoute) => void; + addClientDirective: (directive: ClientDirectiveConfig) => void; // TODO: Add support for `injectElement()` for full HTML element injection, not just scripts. // This may require some refactoring of `scripts`, `styles`, and `links` into something // more generalized. Consider the SSR use-case as well. @@ -1750,6 +1781,7 @@ export interface SSRMetadata { hasDirectives: Set<string>; hasRenderedHead: boolean; headInTree: boolean; + clientDirectives: Map<string, string>; } /** @@ -1815,3 +1847,29 @@ export type CreatePreviewServer = ( export interface PreviewModule { default: CreatePreviewServer; } + +/* Client Directives */ +type DirectiveHydrate = () => Promise<void>; +type DirectiveLoad = () => Promise<DirectiveHydrate>; + +type DirectiveOptions = { + /** + * The component displayName + */ + name: string; + /** + * The attribute value provided + */ + value: string; +}; + +export type ClientDirective = ( + load: DirectiveLoad, + options: DirectiveOptions, + el: HTMLElement +) => void; + +export interface ClientDirectiveConfig { + name: string; + entrypoint: string; +} diff --git a/packages/astro/src/core/app/common.ts b/packages/astro/src/core/app/common.ts index 6fd13d9b9086..58898b2fe51f 100644 --- a/packages/astro/src/core/app/common.ts +++ b/packages/astro/src/core/app/common.ts @@ -15,11 +15,13 @@ export function deserializeManifest(serializedManifest: SerializedSSRManifest): const assets = new Set<string>(serializedManifest.assets); const componentMetadata = new Map(serializedManifest.componentMetadata); + const clientDirectives = new Map(serializedManifest.clientDirectives); return { ...serializedManifest, assets, componentMetadata, + clientDirectives, routes, }; } diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index d7d4241d20d4..8f37bf560828 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -65,6 +65,7 @@ export class App { markdown: manifest.markdown, mode: 'production', renderers: manifest.renderers, + clientDirectives: manifest.clientDirectives, async resolve(specifier: string) { if (!(specifier in manifest.entryModules)) { throw new Error(`Unable to resolve [${specifier}]`); diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index ab6a50b9c946..89c5bad37a4d 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -41,16 +41,24 @@ export interface SSRManifest { markdown: MarkdownRenderingOptions; pageMap: Map<ComponentPath, ComponentInstance>; renderers: SSRLoadedRenderer[]; + /** + * Map of directive name (e.g. `load`) to the directive script code + */ + clientDirectives: Map<string, string>; entryModules: Record<string, string>; assets: Set<string>; componentMetadata: SSRResult['componentMetadata']; middleware?: AstroMiddlewareInstance<unknown>; } -export type SerializedSSRManifest = Omit<SSRManifest, 'routes' | 'assets' | 'componentMetadata'> & { +export type SerializedSSRManifest = Omit< + SSRManifest, + 'routes' | 'assets' | 'componentMetadata' | 'clientDirectives' +> & { routes: SerializedRouteInfo[]; assets: string[]; componentMetadata: [string, SSRComponentMetadata][]; + clientDirectives: [string, string][]; }; export type AdapterCreateExports<T = any> = ( diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 8a85232c4253..e330d472ada9 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -421,6 +421,7 @@ async function generatePath( markdown: settings.config.markdown, mode: opts.mode, renderers, + clientDirectives: settings.clientDirectives, async resolve(specifier: string) { const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier); if (typeof hashedFilePath !== 'string') { diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts index 8259e5e15342..935e7b38059d 100644 --- a/packages/astro/src/core/build/plugins/plugin-ssr.ts +++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts @@ -237,6 +237,7 @@ function buildManifest( pageMap: null as any, componentMetadata: Array.from(internals.componentMetadata), renderers: [], + clientDirectives: Array.from(settings.clientDirectives), entryModules, assets: staticFiles.map(prefixAssetPath), }; diff --git a/packages/astro/src/core/client-directive/build.ts b/packages/astro/src/core/client-directive/build.ts new file mode 100644 index 000000000000..591c0c4372d2 --- /dev/null +++ b/packages/astro/src/core/client-directive/build.ts @@ -0,0 +1,33 @@ +import { build } from 'esbuild'; + +/** + * Build a client directive entrypoint into code that can directly run in a `<script>` tag. + */ +export async function buildClientDirectiveEntrypoint(name: string, entrypoint: string) { + const stringifiedName = JSON.stringify(name); + const stringifiedEntrypoint = JSON.stringify(entrypoint); + + // NOTE: when updating this stdin code, make sure to also update `packages/astro/scripts/prebuild.ts` + // that prebuilds the client directive with a similar code too. + const output = await build({ + stdin: { + contents: `\ +import directive from ${stringifiedEntrypoint}; + +(self.Astro || (self.Astro = {}))[${stringifiedName}] = directive; + +window.dispatchEvent(new Event('astro:' + ${stringifiedName}));`, + resolveDir: process.cwd(), + }, + absWorkingDir: process.cwd(), + format: 'iife', + minify: true, + bundle: true, + write: false, + }); + + const outputFile = output.outputFiles?.[0]; + if (!outputFile) return ''; + + return outputFile.text; +} diff --git a/packages/astro/src/core/client-directive/default.ts b/packages/astro/src/core/client-directive/default.ts new file mode 100644 index 000000000000..352763ba67c7 --- /dev/null +++ b/packages/astro/src/core/client-directive/default.ts @@ -0,0 +1,15 @@ +import idlePrebuilt from '../../runtime/client/idle.prebuilt.js'; +import loadPrebuilt from '../../runtime/client/load.prebuilt.js'; +import mediaPrebuilt from '../../runtime/client/media.prebuilt.js'; +import onlyPrebuilt from '../../runtime/client/only.prebuilt.js'; +import visiblePrebuilt from '../../runtime/client/visible.prebuilt.js'; + +export function getDefaultClientDirectives() { + return new Map([ + ['idle', idlePrebuilt], + ['load', loadPrebuilt], + ['media', mediaPrebuilt], + ['only', onlyPrebuilt], + ['visible', visiblePrebuilt], + ]); +} diff --git a/packages/astro/src/core/client-directive/index.ts b/packages/astro/src/core/client-directive/index.ts new file mode 100644 index 000000000000..7c1a9a71cbaa --- /dev/null +++ b/packages/astro/src/core/client-directive/index.ts @@ -0,0 +1,2 @@ +export { buildClientDirectiveEntrypoint } from './build.js'; +export { getDefaultClientDirectives } from './default.js'; diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index fd8d88c4df58..54640b19fdf8 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -38,6 +38,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = { legacy: {}, experimental: { assets: false, + customClientDirecives: false, inlineStylesheets: 'never', middleware: false, }, @@ -195,6 +196,10 @@ export const AstroConfigSchema = z.object({ experimental: z .object({ assets: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.assets), + customClientDirectives: z + .boolean() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.customClientDirecives), inlineStylesheets: z .enum(['always', 'auto', 'never']) .optional() diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts index 4d8278b80154..fa90af4c0407 100644 --- a/packages/astro/src/core/config/settings.ts +++ b/packages/astro/src/core/config/settings.ts @@ -7,6 +7,7 @@ import { markdownContentEntryType } from '../../vite-plugin-markdown/content-ent import { createDefaultDevConfig } from './config.js'; import { AstroTimer } from './timer.js'; import { loadTSConfig } from './tsconfig.js'; +import { getDefaultClientDirectives } from '../client-directive/index.js'; export function createBaseSettings(config: AstroConfig): AstroSettings { return { @@ -23,6 +24,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings { contentEntryTypes: [markdownContentEntryType], renderers: [jsxRenderer], scripts: [], + clientDirectives: getDefaultClientDirectives(), watchFiles: [], forceDisableTelemetry: false, timer: new AstroTimer(), diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index fd57ad8bc673..1c12a1a8d6e5 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -140,6 +140,7 @@ export async function renderPage({ mod, renderContext, env, apiContext }: Render componentMetadata: renderContext.componentMetadata, resolve: env.resolve, renderers: env.renderers, + clientDirectives: env.clientDirectives, request: renderContext.request, site: env.site, scripts: renderContext.scripts, diff --git a/packages/astro/src/core/render/dev/environment.ts b/packages/astro/src/core/render/dev/environment.ts index 5aa3688ddc9a..6a45f9c36e4d 100644 --- a/packages/astro/src/core/render/dev/environment.ts +++ b/packages/astro/src/core/render/dev/environment.ts @@ -25,6 +25,7 @@ export function createDevelopmentEnvironment( mode, // This will be overridden in the dev server renderers: [], + clientDirectives: settings.clientDirectives, resolve: createResolve(loader, settings.config.root), routeCache: new RouteCache(logging, mode), site: settings.config.site, diff --git a/packages/astro/src/core/render/environment.ts b/packages/astro/src/core/render/environment.ts index 4c5f6bacef37..d4a1cc38ef6e 100644 --- a/packages/astro/src/core/render/environment.ts +++ b/packages/astro/src/core/render/environment.ts @@ -2,6 +2,7 @@ import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark'; import type { RuntimeMode, SSRLoadedRenderer } from '../../@types/astro'; import type { LogOptions } from '../logger/core.js'; import { RouteCache } from './route-cache.js'; +import { getDefaultClientDirectives } from '../client-directive/default.js'; /** * An environment represents the static parts of rendering that do not change @@ -16,6 +17,7 @@ export interface Environment { /** "development" or "production" */ mode: RuntimeMode; renderers: SSRLoadedRenderer[]; + clientDirectives: Map<string, string>; resolve: (s: string) => Promise<string>; routeCache: RouteCache; site?: string; @@ -46,6 +48,7 @@ export function createBasicEnvironment(options: CreateBasicEnvironmentArgs): Env }, mode, renderers: options.renderers ?? [], + clientDirectives: getDefaultClientDirectives(), resolve: options.resolve ?? ((s: string) => Promise.resolve(s)), routeCache: new RouteCache(options.logging, mode), ssr: options.ssr ?? true, diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index 598ec116f785..e18ed7eb9ed6 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -42,6 +42,7 @@ export interface CreateResultArgs { pathname: string; props: Props; renderers: SSRLoadedRenderer[]; + clientDirectives: Map<string, string>; resolve: (s: string) => Promise<string>; site: string | undefined; links?: Set<SSRElement>; @@ -132,7 +133,8 @@ class Slots { let renderMarkdown: any = null; export function createResult(args: CreateResultArgs): SSRResult { - const { markdown, params, pathname, renderers, request, resolve, locals } = args; + const { markdown, params, pathname, renderers, clientDirectives, request, resolve, locals } = + args; const url = new URL(request.url); const headers = new Headers(); @@ -260,6 +262,7 @@ export function createResult(args: CreateResultArgs): SSRResult { hasRenderedHead: false, hasDirectives: new Set(), headInTree: false, + clientDirectives, }, response, }; diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index d306e7be3463..f833d94a1dc8 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -17,6 +17,7 @@ import type { PageBuildData } from '../core/build/types'; import { mergeConfig } from '../core/config/config.js'; import { info, type LogOptions } from '../core/logger/core.js'; import { mdxContentEntryType } from '../vite-plugin-markdown/content-entry-type.js'; +import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.js'; async function withTakingALongTimeMsg<T>({ name, @@ -55,6 +56,7 @@ export async function runHookConfigSetup({ let updatedConfig: AstroConfig = { ...settings.config }; let updatedSettings: AstroSettings = { ...settings, config: updatedConfig }; + let addedClientDirectives = new Map<string, Promise<string>>(); for (const integration of settings.config.integrations) { /** @@ -97,6 +99,19 @@ export async function runHookConfigSetup({ addWatchFile: (path) => { updatedSettings.watchFiles.push(path instanceof URL ? fileURLToPath(path) : path); }, + addClientDirective: ({ name, entrypoint }) => { + if (!settings.config.experimental.customClientDirectives) { + throw new Error( + `The "${integration.name}" integration is trying to add the "${name}" client directive, but the \`experimental.customClientDirectives\` config is not enabled.` + ); + } + if (updatedSettings.clientDirectives.has(name) || addedClientDirectives.has(name)) { + throw new Error( + `The "${integration.name}" integration is trying to add the "${name}" client directive, but it already exists.` + ); + } + addedClientDirectives.set(name, buildClientDirectiveEntrypoint(name, entrypoint)); + }, }; // --- @@ -138,6 +153,11 @@ export async function runHookConfigSetup({ ) { addContentEntryType(mdxContentEntryType); } + + // Add custom client directives to settings, waiting for compiled code by esbuild + for (const [name, compiled] of addedClientDirectives) { + updatedSettings.clientDirectives.set(name, await compiled); + } } } diff --git a/packages/astro/src/jsx/babel.ts b/packages/astro/src/jsx/babel.ts index 861914336a14..9caf42aaf33a 100644 --- a/packages/astro/src/jsx/babel.ts +++ b/packages/astro/src/jsx/babel.ts @@ -3,7 +3,6 @@ import * as t from '@babel/types'; import { AstroErrorData } from '../core/errors/errors-data.js'; import { AstroError } from '../core/errors/errors.js'; import { resolvePath } from '../core/util.js'; -import { HydrationDirectiveProps } from '../runtime/server/hydration.js'; import type { PluginMetadata } from '../vite-plugin-astro/types'; const ClientOnlyPlaceholder = 'astro-client-only'; @@ -285,7 +284,7 @@ export default function astroJSX(): PluginObj { for (const attr of parentNode.openingElement.attributes) { if (t.isJSXAttribute(attr)) { const name = jsxAttributeToString(attr); - if (HydrationDirectiveProps.has(name)) { + if (name.startsWith('client:')) { // eslint-disable-next-line console.warn( `You are attempting to render <${displayName} ${name} />, but ${displayName} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.` diff --git a/packages/astro/src/runtime/client/idle.ts b/packages/astro/src/runtime/client/idle.ts index 4af28bd46180..48aa9dc1f0c9 100644 --- a/packages/astro/src/runtime/client/idle.ts +++ b/packages/astro/src/runtime/client/idle.ts @@ -1,13 +1,15 @@ -(self.Astro = self.Astro || {}).idle = (getHydrateCallback) => { +import type { ClientDirective } from '../../@types/astro'; + +const idleDirective: ClientDirective = (load) => { const cb = async () => { - let hydrate = await getHydrateCallback(); + const hydrate = await load(); await hydrate(); }; - if ('requestIdleCallback' in window) { (window as any).requestIdleCallback(cb); } else { setTimeout(cb, 200); } }; -window.dispatchEvent(new Event('astro:idle')); + +export default idleDirective; diff --git a/packages/astro/src/runtime/client/load.ts b/packages/astro/src/runtime/client/load.ts index 426c6c68aae9..15a2f1dcb8c4 100644 --- a/packages/astro/src/runtime/client/load.ts +++ b/packages/astro/src/runtime/client/load.ts @@ -1,7 +1,8 @@ -(self.Astro = self.Astro || {}).load = (getHydrateCallback) => { - (async () => { - let hydrate = await getHydrateCallback(); - await hydrate(); - })(); +import type { ClientDirective } from '../../@types/astro'; + +const loadDirective: ClientDirective = async (load) => { + const hydrate = await load(); + await hydrate(); }; -window.dispatchEvent(new Event('astro:load')); + +export default loadDirective; diff --git a/packages/astro/src/runtime/client/media.ts b/packages/astro/src/runtime/client/media.ts index c180d396a1e8..3d92d37134d0 100644 --- a/packages/astro/src/runtime/client/media.ts +++ b/packages/astro/src/runtime/client/media.ts @@ -1,9 +1,11 @@ +import type { ClientDirective } from '../../@types/astro'; + /** * Hydrate this component when a matching media query is found */ -(self.Astro = self.Astro || {}).media = (getHydrateCallback, options) => { +const mediaDirective: ClientDirective = (load, options) => { const cb = async () => { - let hydrate = await getHydrateCallback(); + const hydrate = await load(); await hydrate(); }; @@ -16,4 +18,5 @@ } } }; -window.dispatchEvent(new Event('astro:media')); + +export default mediaDirective; diff --git a/packages/astro/src/runtime/client/only.ts b/packages/astro/src/runtime/client/only.ts index e8272edbb429..f67ae3ace33a 100644 --- a/packages/astro/src/runtime/client/only.ts +++ b/packages/astro/src/runtime/client/only.ts @@ -1,10 +1,11 @@ +import type { ClientDirective } from '../../@types/astro'; + /** * Hydrate this component only on the client */ -(self.Astro = self.Astro || {}).only = (getHydrateCallback) => { - (async () => { - let hydrate = await getHydrateCallback(); - await hydrate(); - })(); +const onlyDirective: ClientDirective = async (load) => { + const hydrate = await load(); + await hydrate(); }; -window.dispatchEvent(new Event('astro:only')); + +export default onlyDirective; diff --git a/packages/astro/src/runtime/client/visible.ts b/packages/astro/src/runtime/client/visible.ts index 28975040cf0a..e42b0433996d 100644 --- a/packages/astro/src/runtime/client/visible.ts +++ b/packages/astro/src/runtime/client/visible.ts @@ -1,15 +1,17 @@ +import type { ClientDirective } from '../../@types/astro'; + /** * Hydrate this component when one of it's children becomes visible * We target the children because `astro-island` is set to `display: contents` * which doesn't work with IntersectionObserver */ -(self.Astro = self.Astro || {}).visible = (getHydrateCallback, _opts, root) => { +const visibleDirective: ClientDirective = (load, _options, el) => { const cb = async () => { - let hydrate = await getHydrateCallback(); + const hydrate = await load(); await hydrate(); }; - let io = new IntersectionObserver((entries) => { + const io = new IntersectionObserver((entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; // As soon as we hydrate, disconnect this IntersectionObserver for every `astro-island` @@ -19,9 +21,10 @@ } }); - for (let i = 0; i < root.children.length; i++) { - const child = root.children[i]; + for (let i = 0; i < el.children.length; i++) { + const child = el.children[i]; io.observe(child); } }; -window.dispatchEvent(new Event('astro:visible')); + +export default visibleDirective; diff --git a/packages/astro/src/runtime/server/hydration.ts b/packages/astro/src/runtime/server/hydration.ts index 4729708e7606..9394be581146 100644 --- a/packages/astro/src/runtime/server/hydration.ts +++ b/packages/astro/src/runtime/server/hydration.ts @@ -9,10 +9,6 @@ import { escapeHTML } from './escape.js'; import { serializeProps } from './serialize.js'; import { serializeListValue } from './util.js'; -const HydrationDirectivesRaw = ['load', 'idle', 'media', 'visible', 'only']; -const HydrationDirectives = new Set(HydrationDirectivesRaw); -export const HydrationDirectiveProps = new Set(HydrationDirectivesRaw.map((n) => `client:${n}`)); - export interface HydrationMetadata { directive: string; value: string; @@ -29,8 +25,8 @@ interface ExtractedProps { // Used to extract the directives, aka `client:load` information about a component. // Finds these special props and removes them from what gets passed into the component. export function extractDirectives( - displayName: string, - inputProps: Record<string | number | symbol, any> + inputProps: Record<string | number | symbol, any>, + clientDirectives: SSRResult['_metadata']['clientDirectives'] ): ExtractedProps { let extracted: ExtractedProps = { isPage: false, @@ -74,11 +70,12 @@ export function extractDirectives( extracted.hydration.value = value; // throw an error if an invalid hydration directive was provided - if (!HydrationDirectives.has(extracted.hydration.directive)) { + if (!clientDirectives.has(extracted.hydration.directive)) { + const hydrationMethods = Array.from(clientDirectives.keys()) + .map((d) => `client:${d}`) + .join(', '); throw new Error( - `Error: invalid hydration directive "${key}". Supported hydration methods: ${Array.from( - HydrationDirectiveProps - ).join(', ')}` + `Error: invalid hydration directive "${key}". Supported hydration methods: ${hydrationMethods}` ); } diff --git a/packages/astro/src/runtime/server/render/astro/instance.ts b/packages/astro/src/runtime/server/render/astro/instance.ts index 47ce7f49569a..ed5044575ae5 100644 --- a/packages/astro/src/runtime/server/render/astro/instance.ts +++ b/packages/astro/src/runtime/server/render/astro/instance.ts @@ -2,7 +2,6 @@ import type { SSRResult } from '../../../../@types/astro'; import type { ComponentSlots } from '../slot.js'; import type { AstroComponentFactory, AstroFactoryReturnValue } from './factory.js'; -import { HydrationDirectiveProps } from '../../hydration.js'; import { isPromise } from '../../util.js'; import { renderChild } from '../any.js'; import { isAPropagatingComponent } from './factory.js'; @@ -62,7 +61,7 @@ export class AstroComponentInstance { function validateComponentProps(props: any, displayName: string) { if (props != null) { for (const prop of Object.keys(props)) { - if (HydrationDirectiveProps.has(prop)) { + if (prop.startsWith('client:')) { // eslint-disable-next-line console.warn( `You are attempting to render <${displayName} ${prop} />, but ${displayName} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.` diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts index e9e74f9fa03e..e9be3bf8ba04 100644 --- a/packages/astro/src/runtime/server/render/common.ts +++ b/packages/astro/src/runtime/server/render/common.ts @@ -39,7 +39,7 @@ export function stringifyChunk( ? 'directive' : null; if (prescriptType) { - let prescripts = getPrescripts(prescriptType, hydration.directive); + let prescripts = getPrescripts(result, prescriptType, hydration.directive); return markHTMLString(prescripts); } else { return ''; diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts index cc8851522644..afedd8858b1c 100644 --- a/packages/astro/src/runtime/server/render/component.ts +++ b/packages/astro/src/runtime/server/render/component.ts @@ -67,10 +67,10 @@ async function renderFrameworkComponent( ); } - const { renderers } = result._metadata; + const { renderers, clientDirectives } = result._metadata; const metadata: AstroComponentMetadata = { displayName }; - const { hydration, isPage, props } = extractDirectives(displayName, _props); + const { hydration, isPage, props } = extractDirectives(_props, clientDirectives); let html = ''; let attrs: Record<string, string> | undefined = undefined; diff --git a/packages/astro/src/runtime/server/scripts.ts b/packages/astro/src/runtime/server/scripts.ts index 1d57c07e92f8..b466d1df3871 100644 --- a/packages/astro/src/runtime/server/scripts.ts +++ b/packages/astro/src/runtime/server/scripts.ts @@ -1,12 +1,8 @@ import type { SSRResult } from '../../@types/astro'; - -import idlePrebuilt from '../client/idle.prebuilt.js'; -import loadPrebuilt from '../client/load.prebuilt.js'; -import mediaPrebuilt from '../client/media.prebuilt.js'; -import onlyPrebuilt from '../client/only.prebuilt.js'; -import visiblePrebuilt from '../client/visible.prebuilt.js'; import islandScript from './astro-island.prebuilt.js'; +const ISLAND_STYLES = `<style>astro-island,astro-slot{display:contents}</style>`; + export function determineIfNeedsHydrationScript(result: SSRResult): boolean { if (result._metadata.hasHydrationScript) { return false; @@ -14,14 +10,6 @@ export function determineIfNeedsHydrationScript(result: SSRResult): boolean { return (result._metadata.hasHydrationScript = true); } -export const hydrationScripts: Record<string, string> = { - idle: idlePrebuilt, - load: loadPrebuilt, - only: onlyPrebuilt, - media: mediaPrebuilt, - visible: visiblePrebuilt, -}; - export function determinesIfNeedsDirectiveScript(result: SSRResult, directive: string): boolean { if (result._metadata.hasDirectives.has(directive)) { return false; @@ -32,26 +20,28 @@ export function determinesIfNeedsDirectiveScript(result: SSRResult, directive: s export type PrescriptType = null | 'both' | 'directive'; -function getDirectiveScriptText(directive: string): string { - if (!(directive in hydrationScripts)) { +function getDirectiveScriptText(result: SSRResult, directive: string): string { + const clientDirectives = result._metadata.clientDirectives; + const clientDirective = clientDirectives.get(directive); + if (!clientDirective) { throw new Error(`Unknown directive: ${directive}`); } - const directiveScriptText = hydrationScripts[directive]; - return directiveScriptText; + return clientDirective; } -export function getPrescripts(type: PrescriptType, directive: string): string { +export function getPrescripts(result: SSRResult, type: PrescriptType, directive: string): string { // Note that this is a classic script, not a module script. // This is so that it executes immediate, and when the browser encounters // an astro-island element the callbacks will fire immediately, causing the JS // deps to be loaded immediately. switch (type) { case 'both': - return `<style>astro-island,astro-slot{display:contents}</style><script>${ - getDirectiveScriptText(directive) + islandScript - }</script>`; + return `${ISLAND_STYLES}<script>${getDirectiveScriptText( + result, + directive + )};${islandScript}</script>`; case 'directive': - return `<script>${getDirectiveScriptText(directive)}</script>`; + return `<script>${getDirectiveScriptText(result, directive)}</script>`; } return ''; } diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index f933a13ada33..e26ab8c6d508 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -127,12 +127,21 @@ export async function loadFixture(inlineConfig) { if (inlineConfig.base && !inlineConfig.base.endsWith('/')) { config.base = inlineConfig.base + '/'; } - let settings = createSettings(config, fileURLToPath(cwd)); - if (config.integrations.find((integration) => integration.name === '@astrojs/mdx')) { - // Enable default JSX integration. It needs to come first, so unshift rather than push! - const { default: jsxRenderer } = await import('astro/jsx/renderer.js'); - settings.renderers.unshift(jsxRenderer); - } + + /** + * The dev/build/sync/check commands run integrations' `astro:config:setup` hook that could mutate + * the `AstroSettings`. This function helps to create a fresh settings object that is used by the + * command functions below to prevent tests from polluting each other. + */ + const getSettings = async () => { + let settings = createSettings(config, fileURLToPath(cwd)); + if (config.integrations.find((integration) => integration.name === '@astrojs/mdx')) { + // Enable default JSX integration. It needs to come first, so unshift rather than push! + const { default: jsxRenderer } = await import('astro/jsx/renderer.js'); + settings.renderers.unshift(jsxRenderer); + } + return settings; + }; /** @type {import('@astrojs/telemetry').AstroTelemetry} */ const telemetry = { @@ -170,17 +179,17 @@ export async function loadFixture(inlineConfig) { let devServer; return { - build: (opts = {}) => { + build: async (opts = {}) => { process.env.NODE_ENV = 'production'; - return build(settings, { logging, telemetry, ...opts }); + return build(await getSettings(), { logging, telemetry, ...opts }); }, - sync: (opts) => sync(settings, { logging, fs, ...opts }), + sync: async (opts) => sync(await getSettings(), { logging, fs, ...opts }), check: async (opts) => { - return await check(settings, { logging, ...opts }); + return await check(await getSettings(), { logging, ...opts }); }, startDevServer: async (opts = {}) => { process.env.NODE_ENV = 'development'; - devServer = await dev(settings, { logging, telemetry, ...opts }); + devServer = await dev(await getSettings(), { logging, telemetry, ...opts }); config.server.host = parseAddressToHost(devServer.address.address); // update host config.server.port = devServer.address.port; // update port return devServer; @@ -202,7 +211,7 @@ export async function loadFixture(inlineConfig) { }, preview: async (opts = {}) => { process.env.NODE_ENV = 'production'; - const previewServer = await preview(settings, { + const previewServer = await preview(await getSettings(), { logging, telemetry, ...opts, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35fe04342dd1..f1af7cc696b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -603,6 +603,9 @@ importers: es-module-lexer: specifier: ^1.1.0 version: 1.1.1 + esbuild: + specifier: ^0.17.18 + version: 0.17.18 estree-walker: specifier: 3.0.0 version: 3.0.0 @@ -941,6 +944,21 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/e2e/fixtures/custom-client-directives: + dependencies: + '@astrojs/react': + specifier: workspace:* + version: link:../../../../integrations/react + astro: + specifier: workspace:* + version: link:../../.. + react: + specifier: ^18.0.0 + version: 18.2.0 + react-dom: + specifier: ^18.0.0 + version: 18.2.0(react@18.2.0) + packages/astro/e2e/fixtures/error-cyclic: dependencies: '@astrojs/preact': @@ -7400,6 +7418,14 @@ packages: requiresBuild: true optional: true + /@esbuild/android-arm64@0.17.18: + resolution: {integrity: sha512-/iq0aK0eeHgSC3z55ucMAHO05OIqmQehiGay8eP5l/5l+iEr4EIbh4/MI8xD9qRFjqzgkc0JkX0LculNC9mXBw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + optional: true + /@esbuild/android-arm@0.15.18: resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==} engines: {node: '>=12'} @@ -7417,6 +7443,14 @@ packages: requiresBuild: true optional: true + /@esbuild/android-arm@0.17.18: + resolution: {integrity: sha512-EmwL+vUBZJ7mhFCs5lA4ZimpUH3WMAoqvOIYhVQwdIgSpHC8ImHdsRyhHAVxpDYUSm0lWvd63z0XH1IlImS2Qw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + optional: true + /@esbuild/android-x64@0.17.12: resolution: {integrity: sha512-m4OsaCr5gT+se25rFPHKQXARMyAehHTQAz4XX1Vk3d27VtqiX0ALMBPoXZsGaB6JYryCLfgGwUslMqTfqeLU0w==} engines: {node: '>=12'} @@ -7425,6 +7459,14 @@ packages: requiresBuild: true optional: true + /@esbuild/android-x64@0.17.18: + resolution: {integrity: sha512-x+0efYNBF3NPW2Xc5bFOSFW7tTXdAcpfEg2nXmxegm4mJuVeS+i109m/7HMiOQ6M12aVGGFlqJX3RhNdYM2lWg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + optional: true + /@esbuild/darwin-arm64@0.17.12: resolution: {integrity: sha512-O3GCZghRIx+RAN0NDPhyyhRgwa19MoKlzGonIb5hgTj78krqp9XZbYCvFr9N1eUxg0ZQEpiiZ4QvsOQwBpP+lg==} engines: {node: '>=12'} @@ -7433,6 +7475,14 @@ packages: requiresBuild: true optional: true + /@esbuild/darwin-arm64@0.17.18: + resolution: {integrity: sha512-6tY+djEAdF48M1ONWnQb1C+6LiXrKjmqjzPNPWXhu/GzOHTHX2nh8Mo2ZAmBFg0kIodHhciEgUBtcYCAIjGbjQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optional: true + /@esbuild/darwin-x64@0.17.12: resolution: {integrity: sha512-5D48jM3tW27h1qjaD9UNRuN+4v0zvksqZSPZqeSWggfMlsVdAhH3pwSfQIFJwcs9QJ9BRibPS4ViZgs3d2wsCA==} engines: {node: '>=12'} @@ -7441,6 +7491,14 @@ packages: requiresBuild: true optional: true + /@esbuild/darwin-x64@0.17.18: + resolution: {integrity: sha512-Qq84ykvLvya3dO49wVC9FFCNUfSrQJLbxhoQk/TE1r6MjHo3sFF2tlJCwMjhkBVq3/ahUisj7+EpRSz0/+8+9A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + optional: true + /@esbuild/freebsd-arm64@0.17.12: resolution: {integrity: sha512-OWvHzmLNTdF1erSvrfoEBGlN94IE6vCEaGEkEH29uo/VoONqPnoDFfShi41Ew+yKimx4vrmmAJEGNoyyP+OgOQ==} engines: {node: '>=12'} @@ -7449,6 +7507,14 @@ packages: requiresBuild: true optional: true + /@esbuild/freebsd-arm64@0.17.18: + resolution: {integrity: sha512-fw/ZfxfAzuHfaQeMDhbzxp9mc+mHn1Y94VDHFHjGvt2Uxl10mT4CDavHm+/L9KG441t1QdABqkVYwakMUeyLRA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + optional: true + /@esbuild/freebsd-x64@0.17.12: resolution: {integrity: sha512-A0Xg5CZv8MU9xh4a+7NUpi5VHBKh1RaGJKqjxe4KG87X+mTjDE6ZvlJqpWoeJxgfXHT7IMP9tDFu7IZ03OtJAw==} engines: {node: '>=12'} @@ -7457,6 +7523,14 @@ packages: requiresBuild: true optional: true + /@esbuild/freebsd-x64@0.17.18: + resolution: {integrity: sha512-FQFbRtTaEi8ZBi/A6kxOC0V0E9B/97vPdYjY9NdawyLd4Qk5VD5g2pbWN2VR1c0xhzcJm74HWpObPszWC+qTew==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + optional: true + /@esbuild/linux-arm64@0.17.12: resolution: {integrity: sha512-cK3AjkEc+8v8YG02hYLQIQlOznW+v9N+OI9BAFuyqkfQFR+DnDLhEM5N8QRxAUz99cJTo1rLNXqRrvY15gbQUg==} engines: {node: '>=12'} @@ -7465,6 +7539,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-arm64@0.17.18: + resolution: {integrity: sha512-R7pZvQZFOY2sxUG8P6A21eq6q+eBv7JPQYIybHVf1XkQYC+lT7nDBdC7wWKTrbvMXKRaGudp/dzZCwL/863mZQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-arm@0.17.12: resolution: {integrity: sha512-WsHyJ7b7vzHdJ1fv67Yf++2dz3D726oO3QCu8iNYik4fb5YuuReOI9OtA+n7Mk0xyQivNTPbl181s+5oZ38gyA==} engines: {node: '>=12'} @@ -7473,6 +7555,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-arm@0.17.18: + resolution: {integrity: sha512-jW+UCM40LzHcouIaqv3e/oRs0JM76JfhHjCavPxMUti7VAPh8CaGSlS7cmyrdpzSk7A+8f0hiedHqr/LMnfijg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-ia32@0.17.12: resolution: {integrity: sha512-jdOBXJqcgHlah/nYHnj3Hrnl9l63RjtQ4vn9+bohjQPI2QafASB5MtHAoEv0JQHVb/xYQTFOeuHnNYE1zF7tYw==} engines: {node: '>=12'} @@ -7481,6 +7571,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-ia32@0.17.18: + resolution: {integrity: sha512-ygIMc3I7wxgXIxk6j3V00VlABIjq260i967Cp9BNAk5pOOpIXmd1RFQJQX9Io7KRsthDrQYrtcx7QCof4o3ZoQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-loong64@0.15.18: resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==} engines: {node: '>=12'} @@ -7498,6 +7596,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-loong64@0.17.18: + resolution: {integrity: sha512-bvPG+MyFs5ZlwYclCG1D744oHk1Pv7j8psF5TfYx7otCVmcJsEXgFEhQkbhNW8otDHL1a2KDINW20cfCgnzgMQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-mips64el@0.17.12: resolution: {integrity: sha512-o8CIhfBwKcxmEENOH9RwmUejs5jFiNoDw7YgS0EJTF6kgPgcqLFjgoc5kDey5cMHRVCIWc6kK2ShUePOcc7RbA==} engines: {node: '>=12'} @@ -7506,6 +7612,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-mips64el@0.17.18: + resolution: {integrity: sha512-oVqckATOAGuiUOa6wr8TXaVPSa+6IwVJrGidmNZS1cZVx0HqkTMkqFGD2HIx9H1RvOwFeWYdaYbdY6B89KUMxA==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-ppc64@0.17.12: resolution: {integrity: sha512-biMLH6NR/GR4z+ap0oJYb877LdBpGac8KfZoEnDiBKd7MD/xt8eaw1SFfYRUeMVx519kVkAOL2GExdFmYnZx3A==} engines: {node: '>=12'} @@ -7514,6 +7628,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-ppc64@0.17.18: + resolution: {integrity: sha512-3dLlQO+b/LnQNxgH4l9rqa2/IwRJVN9u/bK63FhOPB4xqiRqlQAU0qDU3JJuf0BmaH0yytTBdoSBHrb2jqc5qQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-riscv64@0.17.12: resolution: {integrity: sha512-jkphYUiO38wZGeWlfIBMB72auOllNA2sLfiZPGDtOBb1ELN8lmqBrlMiucgL8awBw1zBXN69PmZM6g4yTX84TA==} engines: {node: '>=12'} @@ -7522,6 +7644,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-riscv64@0.17.18: + resolution: {integrity: sha512-/x7leOyDPjZV3TcsdfrSI107zItVnsX1q2nho7hbbQoKnmoeUWjs+08rKKt4AUXju7+3aRZSsKrJtaRmsdL1xA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-s390x@0.17.12: resolution: {integrity: sha512-j3ucLdeY9HBcvODhCY4b+Ds3hWGO8t+SAidtmWu/ukfLLG/oYDMaA+dnugTVAg5fnUOGNbIYL9TOjhWgQB8W5g==} engines: {node: '>=12'} @@ -7530,6 +7660,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-s390x@0.17.18: + resolution: {integrity: sha512-cX0I8Q9xQkL/6F5zWdYmVf5JSQt+ZfZD2bJudZrWD+4mnUvoZ3TDDXtDX2mUaq6upMFv9FlfIh4Gfun0tbGzuw==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-x64@0.17.12: resolution: {integrity: sha512-uo5JL3cgaEGotaqSaJdRfFNSCUJOIliKLnDGWaVCgIKkHxwhYMm95pfMbWZ9l7GeW9kDg0tSxcy9NYdEtjwwmA==} engines: {node: '>=12'} @@ -7538,6 +7676,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-x64@0.17.18: + resolution: {integrity: sha512-66RmRsPlYy4jFl0vG80GcNRdirx4nVWAzJmXkevgphP1qf4dsLQCpSKGM3DUQCojwU1hnepI63gNZdrr02wHUA==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/netbsd-x64@0.17.12: resolution: {integrity: sha512-DNdoRg8JX+gGsbqt2gPgkgb00mqOgOO27KnrWZtdABl6yWTST30aibGJ6geBq3WM2TIeW6COs5AScnC7GwtGPg==} engines: {node: '>=12'} @@ -7546,6 +7692,14 @@ packages: requiresBuild: true optional: true + /@esbuild/netbsd-x64@0.17.18: + resolution: {integrity: sha512-95IRY7mI2yrkLlTLb1gpDxdC5WLC5mZDi+kA9dmM5XAGxCME0F8i4bYH4jZreaJ6lIZ0B8hTrweqG1fUyW7jbg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + optional: true + /@esbuild/openbsd-x64@0.17.12: resolution: {integrity: sha512-aVsENlr7B64w8I1lhHShND5o8cW6sB9n9MUtLumFlPhG3elhNWtE7M1TFpj3m7lT3sKQUMkGFjTQBrvDDO1YWA==} engines: {node: '>=12'} @@ -7554,6 +7708,14 @@ packages: requiresBuild: true optional: true + /@esbuild/openbsd-x64@0.17.18: + resolution: {integrity: sha512-WevVOgcng+8hSZ4Q3BKL3n1xTv5H6Nb53cBrtzzEjDbbnOmucEVcZeGCsCOi9bAOcDYEeBZbD2SJNBxlfP3qiA==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + optional: true + /@esbuild/sunos-x64@0.17.12: resolution: {integrity: sha512-qbHGVQdKSwi0JQJuZznS4SyY27tYXYF0mrgthbxXrZI3AHKuRvU+Eqbg/F0rmLDpW/jkIZBlCO1XfHUBMNJ1pg==} engines: {node: '>=12'} @@ -7562,6 +7724,14 @@ packages: requiresBuild: true optional: true + /@esbuild/sunos-x64@0.17.18: + resolution: {integrity: sha512-Rzf4QfQagnwhQXVBS3BYUlxmEbcV7MY+BH5vfDZekU5eYpcffHSyjU8T0xucKVuOcdCsMo+Ur5wmgQJH2GfNrg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + optional: true + /@esbuild/win32-arm64@0.17.12: resolution: {integrity: sha512-zsCp8Ql+96xXTVTmm6ffvoTSZSV2B/LzzkUXAY33F/76EajNw1m+jZ9zPfNJlJ3Rh4EzOszNDHsmG/fZOhtqDg==} engines: {node: '>=12'} @@ -7570,6 +7740,14 @@ packages: requiresBuild: true optional: true + /@esbuild/win32-arm64@0.17.18: + resolution: {integrity: sha512-Kb3Ko/KKaWhjeAm2YoT/cNZaHaD1Yk/pa3FTsmqo9uFh1D1Rfco7BBLIPdDOozrObj2sahslFuAQGvWbgWldAg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + optional: true + /@esbuild/win32-ia32@0.17.12: resolution: {integrity: sha512-FfrFjR4id7wcFYOdqbDfDET3tjxCozUgbqdkOABsSFzoZGFC92UK7mg4JKRc/B3NNEf1s2WHxJ7VfTdVDPN3ng==} engines: {node: '>=12'} @@ -7578,6 +7756,14 @@ packages: requiresBuild: true optional: true + /@esbuild/win32-ia32@0.17.18: + resolution: {integrity: sha512-0/xUMIdkVHwkvxfbd5+lfG7mHOf2FRrxNbPiKWg9C4fFrB8H0guClmaM3BFiRUYrznVoyxTIyC/Ou2B7QQSwmw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + optional: true + /@esbuild/win32-x64@0.17.12: resolution: {integrity: sha512-JOOxw49BVZx2/5tW3FqkdjSD/5gXYeVGPDcB0lvap0gLQshkh1Nyel1QazC+wNxus3xPlsYAgqU1BUmrmCvWtw==} engines: {node: '>=12'} @@ -7586,6 +7772,14 @@ packages: requiresBuild: true optional: true + /@esbuild/win32-x64@0.17.18: + resolution: {integrity: sha512-qU25Ma1I3NqTSHJUOKi9sAH1/Mzuvlke0ioMJRthLXKm7JiSKVwFghlGbDLOO2sARECGhja4xYfRAZNPAkooYg==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + optional: true + /@eslint-community/eslint-utils@4.4.0(eslint@8.38.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -11335,6 +11529,35 @@ packages: '@esbuild/win32-ia32': 0.17.12 '@esbuild/win32-x64': 0.17.12 + /esbuild@0.17.18: + resolution: {integrity: sha512-z1lix43jBs6UKjcZVKOw2xx69ffE2aG0PygLL5qJ9OS/gy0Ewd1gW/PUQIOIQGXBHWNywSc0floSKoMFF8aK2w==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.17.18 + '@esbuild/android-arm64': 0.17.18 + '@esbuild/android-x64': 0.17.18 + '@esbuild/darwin-arm64': 0.17.18 + '@esbuild/darwin-x64': 0.17.18 + '@esbuild/freebsd-arm64': 0.17.18 + '@esbuild/freebsd-x64': 0.17.18 + '@esbuild/linux-arm': 0.17.18 + '@esbuild/linux-arm64': 0.17.18 + '@esbuild/linux-ia32': 0.17.18 + '@esbuild/linux-loong64': 0.17.18 + '@esbuild/linux-mips64el': 0.17.18 + '@esbuild/linux-ppc64': 0.17.18 + '@esbuild/linux-riscv64': 0.17.18 + '@esbuild/linux-s390x': 0.17.18 + '@esbuild/linux-x64': 0.17.18 + '@esbuild/netbsd-x64': 0.17.18 + '@esbuild/openbsd-x64': 0.17.18 + '@esbuild/sunos-x64': 0.17.18 + '@esbuild/win32-arm64': 0.17.18 + '@esbuild/win32-ia32': 0.17.18 + '@esbuild/win32-x64': 0.17.18 + /escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} @@ -17392,7 +17615,7 @@ packages: optional: true dependencies: '@types/node': 18.16.3 - esbuild: 0.17.12 + esbuild: 0.17.18 postcss: 8.4.23 rollup: 3.21.8 sass: 1.52.2 diff --git a/scripts/cmd/prebuild.js b/scripts/cmd/prebuild.js index bb8220eef165..99208a29f257 100644 --- a/scripts/cmd/prebuild.js +++ b/scripts/cmd/prebuild.js @@ -1,4 +1,5 @@ import esbuild from 'esbuild'; +import { red } from 'kleur/colors'; import glob from 'tiny-glob'; import fs from 'fs'; import path from 'path'; @@ -39,11 +40,40 @@ export default async function prebuild(...args) { } async function prebuildFile(filepath) { - const tscode = await fs.promises.readFile(filepath, 'utf-8'); - const esbuildresult = await esbuild.transform(tscode, { - loader: 'ts', + let tscode = await fs.promises.readFile(filepath, 'utf-8'); + // If we're bundling a client directive, modify the code to match `packages/astro/src/core/client-directive/build.ts`. + // If updating this code, make sure to also update that file. + if (filepath.includes(`runtime${path.sep}client`)) { + // `export default xxxDirective` is a convention used in the current client directives that we use + // to make sure we bundle this right. We'll error below if this convention isn't followed. + const newTscode = tscode.replace( + /export default (.*?)Directive/, + (_, name) => + `(self.Astro || (self.Astro = {})).${name} = ${name}Directive;window.dispatchEvent(new Event('astro:${name}'))` + ); + if (newTscode === tscode) { + console.error( + red( + `${filepath} doesn't follow the \`export default xxxDirective\` convention. The prebuilt output may be wrong. ` + + `For more information, check out ${fileURLToPath(import.meta.url)}` + ) + ); + } + tscode = newTscode; + } + const esbuildresult = await esbuild.build({ + stdin: { + contents: tscode, + resolveDir: path.dirname(filepath), + loader: 'ts', + sourcefile: filepath, + }, + format: 'iife', minify, + bundle: true, + write: false, }); + const code = esbuildresult.outputFiles[0].text.trim(); const rootURL = new URL('../../', import.meta.url); const rel = path.relative(fileURLToPath(rootURL), filepath); const mod = `/** @@ -52,7 +82,7 @@ export default async function prebuild(...args) { * to generate this file. */ -export default \`${escapeTemplateLiterals(esbuildresult.code.trim())}\`;`; +export default \`${escapeTemplateLiterals(code)}\`;`; const url = getPrebuildURL(filepath); await fs.promises.writeFile(url, mod, 'utf-8'); }