diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index 8a30ccc1fdcc..c065ed888db1 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -189,6 +189,18 @@ declare module 'astro:middleware' { export * from 'astro/virtual-modules/middleware.js'; } +declare module 'astro:manifest/server' { + type ServerConfigSerialized = import('./dist/types/public/manifest.js').ServerConfigSerialized; + const manifest: ServerConfigSerialized; + export default manifest; +} + +declare module 'astro:manifest/client' { + type ClientConfigSerialized = import('./dist/types/public/manifest.js').ClientConfigSerialized; + const manifest: ClientConfigSerialized; + export default manifest; +} + declare module 'astro:components' { export * from 'astro/components'; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 8e9f510f8923..78a1fbb2e046 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -97,6 +97,7 @@ export const ASTRO_CONFIG_DEFAULTS = { contentIntellisense: false, responsiveImages: false, svg: false, + serializeManifest: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -589,6 +590,10 @@ export const AstroConfigSchema = z.object({ } return svgConfig; }), + serializeManifest: z + .boolean() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.serializeManifest), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/experimental-flags/ for a list of all current experiments.`, diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 1431c698e858..4ed568640c29 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -16,6 +16,7 @@ import { createEnvLoader } from '../env/env-loader.js'; import { astroEnv } from '../env/vite-plugin-env.js'; import { importMetaEnv } from '../env/vite-plugin-import-meta-env.js'; import astroInternationalization from '../i18n/vite-plugin-i18n.js'; +import astroVirtualManifestPlugin from '../manifest/virtual-module.js'; import astroPrefetch from '../prefetch/vite-plugin-prefetch.js'; import astroDevToolbar from '../toolbar/vite-plugin-dev-toolbar.js'; import astroTransitions from '../transitions/vite-plugin-transitions.js'; @@ -141,6 +142,7 @@ export async function createVite( exclude: ['astro', 'node-fetch'], }, plugins: [ + astroVirtualManifestPlugin({ settings, logger }), configAliasVitePlugin({ settings }), astroLoadFallbackPlugin({ fs, root: settings.config.root }), astroVitePlugin({ settings, logger }), diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 251063621967..185adcfb604a 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1782,6 +1782,18 @@ export const ActionCalledFromServerError = { hint: 'See the `Astro.callAction()` reference for usage examples: https://docs.astro.build/en/reference/api-reference/#callaction', } satisfies ErrorData; +/** + * @docs + * @description + * Cannot the module without enabling the experimental feature + */ +export const CantUseManifestModule = { + name: 'CantUseManifestModule', + title: 'Cannot the module without enabling the experimental feature.', + message: (moduleName) => + `Cannot import the module "${moduleName}" because the experimental feature is disabled. Enable \`experimental.serializeManifest\` in your \`astro.config.mjs\` `, +} satisfies ErrorData; + // Generic catch-all - Only use this in extreme cases, like if there was a cosmic ray bit flip. export const UnknownError = { name: 'UnknownError', title: 'Unknown Error.' } satisfies ErrorData; diff --git a/packages/astro/src/manifest/virtual-module.ts b/packages/astro/src/manifest/virtual-module.ts new file mode 100644 index 000000000000..63acba15dca1 --- /dev/null +++ b/packages/astro/src/manifest/virtual-module.ts @@ -0,0 +1,102 @@ +import type { Plugin } from 'vite'; +import { CantUseManifestModule } from '../core/errors/errors-data.js'; +import { AstroError, AstroErrorData } from '../core/errors/index.js'; +import type { Logger } from '../core/logger/core.js'; +import type { AstroSettings } from '../types/astro.js'; +import type { + AstroConfig, + ClientConfigSerialized, + ServerConfigSerialized, +} from '../types/public/index.js'; + +const VIRTUAL_SERVER_ID = 'astro:manifest/server'; +const RESOLVED_VIRTUAL_SERVER_ID = '\0' + VIRTUAL_SERVER_ID; +const VIRTUAL_CLIENT_ID = 'astro:manifest/client'; +const RESOLVED_VIRTUAL_CLIENT_ID = '\0' + VIRTUAL_CLIENT_ID; + +export default function virtualModulePlugin({ + settings, + logger: _logger, +}: { settings: AstroSettings; logger: Logger }): Plugin { + return { + enforce: 'pre', + name: 'astro-manifest-plugin', + resolveId(id) { + // Resolve the virtual module + if (VIRTUAL_SERVER_ID === id) { + return RESOLVED_VIRTUAL_SERVER_ID; + } else if (VIRTUAL_CLIENT_ID === id) { + return RESOLVED_VIRTUAL_CLIENT_ID; + } + }, + load(id, opts) { + // client + if (id === RESOLVED_VIRTUAL_CLIENT_ID) { + if (!settings.config.experimental.serializeManifest) { + throw new AstroError({ + ...CantUseManifestModule, + message: CantUseManifestModule.message(VIRTUAL_CLIENT_ID), + }); + } + // There's nothing wrong about using `/client` on the server + return `${serializeClientConfig(settings.config)};`; + } + // server + else if (id == RESOLVED_VIRTUAL_SERVER_ID) { + if (!settings.config.experimental.serializeManifest) { + throw new AstroError({ + ...CantUseManifestModule, + message: CantUseManifestModule.message(VIRTUAL_SERVER_ID), + }); + } + if (!opts?.ssr) { + throw new AstroError({ + ...AstroErrorData.ServerOnlyModule, + message: AstroErrorData.ServerOnlyModule.message(VIRTUAL_SERVER_ID), + }); + } + return `${serializeServerConfig(settings.config)};`; + } + }, + }; +} + +function serializeClientConfig(config: AstroConfig): string { + const serClientConfig: ClientConfigSerialized = { + base: config.base, + i18n: config.i18n, + build: { + format: config.build.format, + redirects: config.build.redirects, + }, + trailingSlash: config.trailingSlash, + compressHTML: config.compressHTML, + site: config.site, + legacy: config.legacy, + }; + + const output = []; + for (const [key, value] of Object.entries(serClientConfig)) { + output.push(`export const ${key} = ${JSON.stringify(value)};`); + } + return output.join('\n') + '\n'; +} + +function serializeServerConfig(config: AstroConfig): string { + const serverConfig: ServerConfigSerialized = { + build: { + client: config.build.client, + server: config.build.server, + }, + cacheDir: config.cacheDir, + outDir: config.outDir, + publicDir: config.publicDir, + srcDir: config.srcDir, + root: config.root, + }; + const output = []; + for (const [key, value] of Object.entries(serverConfig)) { + output.push(`export const ${key} = ${JSON.stringify(value)};`); + } + return output.join('\n') + '\n'; +} diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 635e57798c66..77c7ec31bd61 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -2059,6 +2059,19 @@ export interface ViteUserConfig extends OriginalViteUserConfig { */ mode: SvgRenderMode; }; + + /** + * @name experimental.serializeManifest + * @type {boolean} + * @default `false` + * @version 5.x + * @description + * + * Allows to use the virtual modules `astro:manifest/server` and `astro:manifest/client`. + * + * These two virtual modules contain a serializable subset of the Astro configuration. + */ + serializeManifest?: boolean; }; } diff --git a/packages/astro/src/types/public/index.ts b/packages/astro/src/types/public/index.ts index 5df509013678..fae134bbeb01 100644 --- a/packages/astro/src/types/public/index.ts +++ b/packages/astro/src/types/public/index.ts @@ -9,6 +9,7 @@ export type * from './context.js'; export type * from './preview.js'; export type * from './content.js'; export type * from './common.js'; +export type * from './manifest.js'; export type { AstroIntegrationLogger } from '../../core/logger/core.js'; export type { ToolbarServerHelpers } from '../../runtime/client/dev-toolbar/helpers.js'; diff --git a/packages/astro/src/types/public/manifest.ts b/packages/astro/src/types/public/manifest.ts new file mode 100644 index 000000000000..2263e2c9f7c7 --- /dev/null +++ b/packages/astro/src/types/public/manifest.ts @@ -0,0 +1,26 @@ +/** + * **IMPORTANT**: use the `Pick` interface to select only the properties that we want to expose + * to the users. Using blanket types could expose properties that we don't want. So if we decide to expose + * properties, we need to be good at justifying them. For example: why you need this config? can't you use an integration? + * why do you need access to the shiki config? (very low-level confiig) + */ + +import type { AstroConfig } from './config.js'; + +export type SerializedClientBuild = Pick; + +export type SerializedServerBuild = Pick; + +export type ClientConfigSerialized = Pick< + AstroConfig, + 'base' | 'i18n' | 'trailingSlash' | 'compressHTML' | 'site' | 'legacy' +> & { + build: SerializedClientBuild; +}; + +export type ServerConfigSerialized = Pick< + AstroConfig, + 'cacheDir' | 'outDir' | 'publicDir' | 'srcDir' | 'root' +> & { + build: SerializedServerBuild; +}; diff --git a/packages/astro/test/fixtures/astro-manifest/astro.config.mjs b/packages/astro/test/fixtures/astro-manifest/astro.config.mjs new file mode 100644 index 000000000000..ac1b28a80048 --- /dev/null +++ b/packages/astro/test/fixtures/astro-manifest/astro.config.mjs @@ -0,0 +1,13 @@ +import { defineConfig } from "astro/config"; + +// https://astro.build/config +export default defineConfig({ + site: "https://astro.build/", + experimental: { + serializeManifest: true, + }, + i18n: { + locales: ["en", "fr"], + defaultLocale: "en", + } +}); diff --git a/packages/astro/test/fixtures/astro-manifest/package.json b/packages/astro/test/fixtures/astro-manifest/package.json new file mode 100644 index 000000000000..3334ebd6d9b2 --- /dev/null +++ b/packages/astro/test/fixtures/astro-manifest/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/astro-manifest", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/astro-manifest/src/pages/client.astro b/packages/astro/test/fixtures/astro-manifest/src/pages/client.astro new file mode 100644 index 000000000000..a045b8f2e916 --- /dev/null +++ b/packages/astro/test/fixtures/astro-manifest/src/pages/client.astro @@ -0,0 +1,20 @@ +--- +--- + + + + + + + Document + + +

Hello, World!

+

Welcome to this Astro page.

+ + + diff --git a/packages/astro/test/fixtures/astro-manifest/src/pages/index.astro b/packages/astro/test/fixtures/astro-manifest/src/pages/index.astro new file mode 100644 index 000000000000..a3d19033b6f8 --- /dev/null +++ b/packages/astro/test/fixtures/astro-manifest/src/pages/index.astro @@ -0,0 +1,19 @@ + +--- +import { base, i18n, trailingSlash, compressHTML, site, legacy, build } from "astro:manifest/client"; + +const config = JSON.stringify({ base, i18n, build, trailingSlash, compressHTML, site, legacy }); +--- + + + + + + Document + + +

Hello, World!

+

Welcome to this Astro page.

+

{config}

+ + diff --git a/packages/astro/test/fixtures/astro-manifest/src/pages/server.astro b/packages/astro/test/fixtures/astro-manifest/src/pages/server.astro new file mode 100644 index 000000000000..e8e0b1362864 --- /dev/null +++ b/packages/astro/test/fixtures/astro-manifest/src/pages/server.astro @@ -0,0 +1,21 @@ +--- +import { root, outDir, srcDir, build, cacheDir } from "astro:manifest/server"; +--- + + + + + + Document + + +

Hello, World!

+

Welcome to this Astro page.

+

{outDir}

+

{srcDir}

+

{root}

+

{cacheDir}

+

{build.client}

+

{build.server}

+ + diff --git a/packages/astro/test/serializeManifest.test.js b/packages/astro/test/serializeManifest.test.js new file mode 100644 index 000000000000..869cf24e0e86 --- /dev/null +++ b/packages/astro/test/serializeManifest.test.js @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { ServerOnlyModule } from '../dist/core/errors/errors-data.js'; +import { AstroError } from '../dist/core/errors/index.js'; +import { loadFixture } from './test-utils.js'; + +describe('astro:manifest/client', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let devServer; + + describe('when the experimental flag is not enabled', async () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-manifest/', + experimental: { + serializeManifest: false, + }, + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should throw an error when importing the module', async () => { + const response = await fixture.fetch('/'); + const html = await response.text(); + assert.match(html, /CantUseManifestModule/); + }); + }); + + describe('when the experimental flag is enabled', async () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-manifest/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should return the expected properties', async () => { + const response = await fixture.fetch('/'); + const html = await response.text(); + + const $ = cheerio.load(html); + + assert.deepEqual( + $('#config').text(), + JSON.stringify({ + base: '/', + i18n: { + defaultLocale: 'en', + locales: ['en', 'fr'], + routing: { + prefixDefaultLocale: false, + redirectToDefaultLocale: true, + fallbackType: 'redirect', + }, + }, + build: { + format: 'directory', + redirects: true, + }, + trailingSlash: 'ignore', + compressHTML: true, + site: 'https://astro.build/', + legacy: { + collections: false, + }, + }), + ); + }); + }); +}); + +describe('astro:manifest/server', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let devServer; + + describe('when build', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-manifest/', + }); + }); + + it('should return an error when using inside a client script', async () => { + const error = await fixture.build().catch((err) => err); + assert.equal(error instanceof AstroError, true); + assert.equal(error.name, ServerOnlyModule.name); + }); + }); + + describe('when the experimental flag is not enabled', async () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-manifest/', + experimental: { + serializeManifest: false, + }, + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should throw an error when importing the module', async () => { + const response = await fixture.fetch('/server'); + const html = await response.text(); + assert.match(html, /CantUseManifestModule/); + }); + }); + + describe('when the experimental flag is enabled', async () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-manifest/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should return the expected properties', async () => { + const response = await fixture.fetch('/server'); + const html = await response.text(); + + const $ = cheerio.load(html); + + assert.ok($('#out-dir').text().endsWith('/dist/')); + assert.ok($('#src-dir').text().endsWith('/src/')); + assert.ok($('#cache-dir').text().endsWith('/.astro/')); + assert.ok($('#root').text().endsWith('/')); + assert.ok($('#build-client').text().endsWith('/dist/client/')); + assert.ok($('#build-server').text().endsWith('/dist/server/')); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c43a2a7c8b5..cb7eed87188e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2159,6 +2159,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/astro-manifest: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/astro-markdown: dependencies: astro: