diff --git a/.changeset/brave-colts-cover.md b/.changeset/brave-colts-cover.md new file mode 100644 index 0000000000..1819588021 --- /dev/null +++ b/.changeset/brave-colts-cover.md @@ -0,0 +1,35 @@ +--- +"astro": minor +--- + +Introduces an experimental Container API to render `.astro` components in isolation. + +This API introduces three new functions to allow you to create a new container and render an Astro component returning either a string or a Response: + +- `create()`: creates a new instance of the container. +- `renderToString()`: renders a component and return a string. +- `renderToResponse()`: renders a component and returns the `Response` emitted by the rendering phase. + +The first supported use of this new API is to enable unit testing. For example, with `vitest`, you can create a container to render your component with test data and check the result: + +```js +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import { expect, test } from 'vitest'; +import Card from '../src/components/Card.astro'; + +test('Card with slots', async () => { + const container = await AstroContainer.create(); + const result = await container.renderToString(Card, { + slots: { + default: 'Card content', + }, + }); + + expect(result).toContain('This is a card'); + expect(result).toContain('Card content'); +}); +``` + +For a complete reference, see the [Container API docs](/en/reference/container-reference/). + +For a feature overview, and to give feedback on this experimental API, see the [Container API roadmap discussion](https://github.com/withastro/roadmap/pull/916). diff --git a/examples/container-with-vitest/.codesandbox/Dockerfile b/examples/container-with-vitest/.codesandbox/Dockerfile new file mode 100644 index 0000000000..c3b5c81a12 --- /dev/null +++ b/examples/container-with-vitest/.codesandbox/Dockerfile @@ -0,0 +1 @@ +FROM node:18-bullseye diff --git a/examples/container-with-vitest/.gitignore b/examples/container-with-vitest/.gitignore new file mode 100644 index 0000000000..16d54bb13c --- /dev/null +++ b/examples/container-with-vitest/.gitignore @@ -0,0 +1,24 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ diff --git a/examples/container-with-vitest/README.md b/examples/container-with-vitest/README.md new file mode 100644 index 0000000000..116268944c --- /dev/null +++ b/examples/container-with-vitest/README.md @@ -0,0 +1,11 @@ +# Astro + [Vitest](https://vitest.dev/) + Container API Example + +```sh +npm create astro@latest -- --template container-with-vitest +``` + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/with-vitest) +[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/with-vitest) +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/with-vitest/devcontainer.json) + +This example showcases Astro working with [Vitest](https://vitest.dev/) and how to test components using the Container API. diff --git a/examples/container-with-vitest/astro.config.ts b/examples/container-with-vitest/astro.config.ts new file mode 100644 index 0000000000..17257d4f19 --- /dev/null +++ b/examples/container-with-vitest/astro.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import react from "@astrojs/react" + +// https://astro.build/config +export default defineConfig({ + integrations: [react()] +}); diff --git a/examples/container-with-vitest/package.json b/examples/container-with-vitest/package.json new file mode 100644 index 0000000000..9885ba7182 --- /dev/null +++ b/examples/container-with-vitest/package.json @@ -0,0 +1,25 @@ +{ + "name": "@example/container-with-vitest", + "type": "module", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro", + "test": "vitest run" + }, + "dependencies": { + "astro": "experimental--container", + "@astrojs/react": "^3.3.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "vitest": "^1.6.0" + }, + "devDependencies": { + "@types/react-dom": "^18.3.0", + "@types/react": "^18.3.2" + } +} diff --git a/examples/container-with-vitest/public/favicon.svg b/examples/container-with-vitest/public/favicon.svg new file mode 100644 index 0000000000..f157bd1c5e --- /dev/null +++ b/examples/container-with-vitest/public/favicon.svg @@ -0,0 +1,9 @@ + + + + diff --git a/examples/container-with-vitest/src/components/Card.astro b/examples/container-with-vitest/src/components/Card.astro new file mode 100644 index 0000000000..776c823292 --- /dev/null +++ b/examples/container-with-vitest/src/components/Card.astro @@ -0,0 +1,7 @@ +--- + +--- +
+ This is a card + +
diff --git a/examples/container-with-vitest/src/components/Counter.jsx b/examples/container-with-vitest/src/components/Counter.jsx new file mode 100644 index 0000000000..2148bf3d8f --- /dev/null +++ b/examples/container-with-vitest/src/components/Counter.jsx @@ -0,0 +1,14 @@ +import { useState } from 'react'; + +export default function({ initialCount }) { + const [count, setCount] = useState(initialCount || 0); + return ( +
+

Counter

+

Count: {count}

+ +
+ ) +} diff --git a/examples/container-with-vitest/src/components/ReactWrapper.astro b/examples/container-with-vitest/src/components/ReactWrapper.astro new file mode 100644 index 0000000000..73ac6baebd --- /dev/null +++ b/examples/container-with-vitest/src/components/ReactWrapper.astro @@ -0,0 +1,5 @@ +--- +import Counter from './Counter.jsx'; +--- + + diff --git a/examples/container-with-vitest/src/pages/[locale].astro b/examples/container-with-vitest/src/pages/[locale].astro new file mode 100644 index 0000000000..55e5c186a6 --- /dev/null +++ b/examples/container-with-vitest/src/pages/[locale].astro @@ -0,0 +1,22 @@ +--- +export function getStaticPaths() { + return [ + {params: {locale: 'en'}}, + ]; +} +const { locale } = Astro.params +--- + + + + + + + + Astro + + +

Astro

+

Locale: {locale}

+ + diff --git a/examples/container-with-vitest/src/pages/api.ts b/examples/container-with-vitest/src/pages/api.ts new file mode 100644 index 0000000000..c30def5bba --- /dev/null +++ b/examples/container-with-vitest/src/pages/api.ts @@ -0,0 +1,11 @@ +export function GET() { + const json = { + foo: 'bar', + number: 1, + }; + return new Response(JSON.stringify(json), { + headers: { + 'content-type': 'application/json', + }, + }); +} diff --git a/examples/container-with-vitest/src/pages/index.astro b/examples/container-with-vitest/src/pages/index.astro new file mode 100644 index 0000000000..2d14107362 --- /dev/null +++ b/examples/container-with-vitest/src/pages/index.astro @@ -0,0 +1,16 @@ +--- + +--- + + + + + + + + Astro + + +

Astro

+ + diff --git a/examples/container-with-vitest/test/Card.test.ts b/examples/container-with-vitest/test/Card.test.ts new file mode 100644 index 0000000000..26d766d1a8 --- /dev/null +++ b/examples/container-with-vitest/test/Card.test.ts @@ -0,0 +1,15 @@ +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import { expect, test } from 'vitest'; +import Card from '../src/components/Card.astro'; + +test('Card with slots', async () => { + const container = await AstroContainer.create(); + const result = await container.renderToString(Card, { + slots: { + default: 'Card content', + }, + }); + + expect(result).toContain('This is a card'); + expect(result).toContain('Card content'); +}); diff --git a/examples/container-with-vitest/test/ReactWrapper.test.ts b/examples/container-with-vitest/test/ReactWrapper.test.ts new file mode 100644 index 0000000000..2f21d85966 --- /dev/null +++ b/examples/container-with-vitest/test/ReactWrapper.test.ts @@ -0,0 +1,19 @@ +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import { expect, test } from 'vitest'; +import ReactWrapper from '../src/components/ReactWrapper.astro'; + +test('ReactWrapper with react renderer', async () => { + const container = await AstroContainer.create({ + renderers: [ + { + name: '@astrojs/react', + clientEntrypoint: "@astrojs/react/client.js", + serverEntrypoint: "@astrojs/react/server.js", + } + ] + }); + const result = await container.renderToString(ReactWrapper); + + expect(result).toContain('Counter'); + expect(result).toContain('Count: 5'); +}); diff --git a/examples/container-with-vitest/test/[locale].test.ts b/examples/container-with-vitest/test/[locale].test.ts new file mode 100644 index 0000000000..f58a26c49b --- /dev/null +++ b/examples/container-with-vitest/test/[locale].test.ts @@ -0,0 +1,16 @@ +import { experimental_AstroContainer as AstroContainer } from 'astro/container'; +import { expect, test } from 'vitest'; +import Locale from '../src/pages/[locale].astro'; + +test('Dynamic route', async () => { + const container = await AstroContainer.create(); + // @ts-ignore + const result = await container.renderToString(Locale, { + params: { + "locale": 'en' + }, + request: new Request('http://example.com/en'), + }); + + expect(result).toContain('Locale: en'); +}); diff --git a/examples/container-with-vitest/tsconfig.json b/examples/container-with-vitest/tsconfig.json new file mode 100644 index 0000000000..d78f81ec4e --- /dev/null +++ b/examples/container-with-vitest/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "astro/tsconfigs/base" +} diff --git a/examples/container-with-vitest/vitest.config.ts b/examples/container-with-vitest/vitest.config.ts new file mode 100644 index 0000000000..a34f19bb10 --- /dev/null +++ b/examples/container-with-vitest/vitest.config.ts @@ -0,0 +1,9 @@ +/// +import { getViteConfig } from 'astro/config'; + +export default getViteConfig({ + test: { + /* for example, use global to avoid globals imports (describe, test, expect): */ + // globals: true, + }, +}); diff --git a/packages/astro/package.json b/packages/astro/package.json index ce0967346a..8d276aa8a2 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -48,6 +48,10 @@ "types": "./config.d.ts", "default": "./config.mjs" }, + "./container": { + "types": "./dist/container/index.d.ts", + "default": "./dist/container/index.js" + }, "./app": "./dist/core/app/index.js", "./app/node": "./dist/core/app/node.js", "./client/*": "./dist/runtime/client/*", diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts new file mode 100644 index 0000000000..50277dbf04 --- /dev/null +++ b/packages/astro/src/container/index.ts @@ -0,0 +1,416 @@ +import type { + ComponentInstance, + MiddlewareHandler, + RouteData, + RouteType, + SSRLoadedRenderer, + SSRManifest, + SSRResult, + AstroUserConfig, + AstroRenderer, +} from '../@types/astro.js'; +import { ContainerPipeline } from './pipeline.js'; +import { Logger } from '../core/logger/core.js'; +import { nodeLogDestination } from '../core/logger/node.js'; +import { validateConfig } from '../core/config/config.js'; +import { ASTRO_CONFIG_DEFAULTS } from '../core/config/schema.js'; +import { RenderContext } from '../core/render-context.js'; +import { posix } from 'node:path'; +import { getParts, getPattern, validateSegment } from '../core/routing/manifest/create.js'; +import { removeLeadingForwardSlash } from '../core/path.js'; +import type {AstroComponentFactory} from "../runtime/server/index.js"; + +/** + * Options to be passed when rendering a route + */ +export type ContainerRenderOptions = { + /** + * If your component renders slots, that's where you want to fill the slots. + * A single slot should have the `default` field: + * + * ## Examples + * + * **Default slot** + * + * ```js + * container.renderToString(Component, { slots: { default: "Some value"}}); + * ``` + * + * **Named slots** + * + * ```js + * container.renderToString(Component, { slots: { "foo": "Some value", "bar": "Lorem Ipsum" }}); + * ``` + */ + slots?: Record; + /** + * The request is used to understand which path/URL the component is about to render. + * + * Use this option in case your component or middleware needs to read information like `Astro.url` or `Astro.request`. + */ + request?: Request; + /** + * Useful for dynamic routes. If your component is something like `src/pages/blog/[id]/[...slug]`, you'll want to provide: + * ```js + * container.renderToString(Component, { params: ["id", "...slug"] }); + * ``` + */ + params?: Record; + /** + * Useful if your component needs to access some locals without the use a middleware. + * ```js + * container.renderToString(Component, { locals: { getSomeValue() {} } }); + * ``` + */ + locals?: App.Locals; + /** + * Useful in case you're attempting to render an endpoint: + * ```js + * container.renderToString(Endpoint, { routeType: "endpoint" }); + * ``` + */ + routeType?: RouteType; +}; + +function createManifest( + renderers: SSRLoadedRenderer[], + manifest?: AstroContainerManifest, + middleware?: MiddlewareHandler +): SSRManifest { + const defaultMiddleware: MiddlewareHandler = (_, next) => { + return next(); + }; + + return { + rewritingEnabled: false, + trailingSlash: manifest?.trailingSlash ?? ASTRO_CONFIG_DEFAULTS.trailingSlash , + buildFormat: manifest?.buildFormat ?? ASTRO_CONFIG_DEFAULTS.build.format, + compressHTML: manifest?.compressHTML ?? ASTRO_CONFIG_DEFAULTS.compressHTML, + assets: manifest?.assets ?? new Set(), + assetsPrefix: manifest?.assetsPrefix ?? undefined, + entryModules: manifest?.entryModules ?? {}, + routes: manifest?.routes ?? [], + adapterName: '', + clientDirectives: manifest?.clientDirectives ?? new Map(), + renderers: manifest?.renderers ?? renderers, + base: manifest?.base ?? ASTRO_CONFIG_DEFAULTS.base, + componentMetadata: manifest?.componentMetadata ?? new Map(), + inlinedScripts: manifest?.inlinedScripts ?? new Map(), + i18n: manifest?.i18n, + checkOrigin: false, + middleware: manifest?.middleware ?? middleware ?? defaultMiddleware, + }; +} + +export type AstroContainerUserConfig = Omit + +/** + * Options that are used for the entire lifecycle of the current instance of the container. + */ +export type AstroContainerOptions = { + /** + * @default false + * + * @description + * + * Enables streaming during rendering + * + * ## Example + * + * ```js + * const container = await AstroContainer.create({ + * streaming: true + * }); + * ``` + */ + streaming?: boolean; + /** + * @default [] + * @description + * + * List or renderers to use when rendering components. Usually they are entry points + * + * ## Example + * + * ```js + * const container = await AstroContainer.create({ + * renderers: [{ + * name: "@astrojs/react" + * client: "@astrojs/react/client.js" + * server: "@astrojs/react/server.js" + * }] + * }); + * ``` + */ + renderers?: AstroRenderer[]; + /** + * @default {} + * @description + * + * A subset of the astro configuration object. + * + * ## Example + * + * ```js + * const container = await AstroContainer.create({ + * astroConfig: { + * trailingSlash: "never" + * } + * }); + * ``` + */ + astroConfig?: AstroContainerUserConfig; +}; + +type AstroContainerManifest = Pick< + SSRManifest, + | 'middleware' + | 'clientDirectives' + | 'inlinedScripts' + | 'componentMetadata' + | 'renderers' + | 'assetsPrefix' + | 'base' + | 'routes' + | 'assets' + | 'entryModules' + | 'compressHTML' + | 'trailingSlash' + | 'buildFormat' + | 'i18n' +>; + +type AstroContainerConstructor = { + streaming?: boolean; + renderers?: SSRLoadedRenderer[]; + manifest?: AstroContainerManifest; + resolve?: SSRResult['resolve']; +}; + +export class experimental_AstroContainer { + #pipeline: ContainerPipeline; + + /** + * Internally used to check if the container was created with a manifest. + * @private + */ + #withManifest = false; + + private constructor({ + streaming = false, + renderers = [], + manifest, + resolve, + }: AstroContainerConstructor) { + this.#pipeline = ContainerPipeline.create({ + logger: new Logger({ + level: 'info', + dest: nodeLogDestination, + }), + manifest: createManifest(renderers, manifest), + streaming, + serverLike: true, + renderers, + resolve: async (specifier: string) => { + if (this.#withManifest) { + return this.#containerResolve(specifier); + } else if (resolve) { + return resolve(specifier); + } + return specifier; + }, + }); + } + + async #containerResolve(specifier: string): Promise { + const found = this.#pipeline.manifest.entryModules[specifier]; + if (found) { + return new URL(found, ASTRO_CONFIG_DEFAULTS.build.client).toString(); + } + return found; + } + + /** + * Creates a new instance of a container. + * + * @param {AstroContainerOptions=} containerOptions + */ + public static async create( + containerOptions: AstroContainerOptions = {} + ): Promise { + const { + streaming = false, + renderers = [], + } = containerOptions; + const loadedRenderers = await Promise.all( + renderers.map(async (renderer) => { + const mod = await import(renderer.serverEntrypoint); + if (typeof mod.default !== 'undefined') { + return { + ...renderer, + ssr: mod.default, + } as SSRLoadedRenderer; + } + return undefined; + }) + ); + const finalRenderers = loadedRenderers.filter((r): r is SSRLoadedRenderer => Boolean(r)); + + return new experimental_AstroContainer({ streaming, renderers: finalRenderers }); + } + + // NOTE: we keep this private via TS instead via `#` so it's still available on the surface, so we can play with it. + // @ematipico: I plan to use it for a possible integration that could help people + private static async createFromManifest(manifest: SSRManifest): Promise { + const config = await validateConfig(ASTRO_CONFIG_DEFAULTS, process.cwd(), 'container'); + const container = new experimental_AstroContainer({ + manifest, + }); + container.#withManifest = true; + return container; + } + + #insertRoute({ + path, + componentInstance, + params = {}, + type = 'page', + }: { + path: string; + componentInstance: ComponentInstance; + route?: string, + params?: Record; + type?: RouteType; + }): RouteData { + const pathUrl = new URL(path, 'https://example.com'); + const routeData: RouteData = this.#createRoute(pathUrl, + params, type); + this.#pipeline.manifest.routes.push({ + routeData, + file: '', + links: [], + styles: [], + scripts: [], + }); + this.#pipeline.insertRoute(routeData, componentInstance); + return routeData; + } + + /** + * @description + * It renders a component and returns the result as a string. + * + * ## Example + * + * ```js + * import Card from "../src/components/Card.astro"; + * + * const container = await AstroContainer.create(); + * const result = await container.renderToString(Card); + * + * console.log(result); // it's a string + * ``` + * + * + * @param {AstroComponentFactory} component The instance of the component. + * @param {ContainerRenderOptions=} options Possible options to pass when rendering the component. + */ + public async renderToString( + component: AstroComponentFactory, + options: ContainerRenderOptions = {} + ): Promise { + const response = await this.renderToResponse(component, options); + return await response.text(); + } + + /** + * @description + * It renders a component and returns the `Response` as result of the rendering phase. + * + * ## Example + * + * ```js + * import Card from "../src/components/Card.astro"; + * + * const container = await AstroContainer.create(); + * const response = await container.renderToResponse(Card); + * + * console.log(response.status); // it's a number + * ``` + * + * + * @param {AstroComponentFactory} component The instance of the component. + * @param {ContainerRenderOptions=} options Possible options to pass when rendering the component. + */ + public async renderToResponse( + component: AstroComponentFactory, + options: ContainerRenderOptions = {} + ): Promise { + const { routeType = 'page', slots } = options; + const request = options?.request ?? new Request('https://example.com/'); + const url = new URL(request.url); + const componentInstance = routeType === "endpoint" ? component as unknown as ComponentInstance : this.#wrapComponent(component, options.params); + const routeData = this.#insertRoute({ + path: request.url, + componentInstance, + params: options.params, + type: routeType, + }); + const renderContext = RenderContext.create({ + pipeline: this.#pipeline, + routeData, + status: 200, + middleware: this.#pipeline.middleware, + request, + pathname: url.pathname, + locals: options?.locals ?? {}, + }); + if (options.params) { + renderContext.params = options.params; + } + + return renderContext.render(componentInstance, slots); + } + + #createRoute(url: URL, params: Record, type: RouteType): RouteData { + const segments = removeLeadingForwardSlash(url.pathname) + .split(posix.sep) + .filter(Boolean) + .map((s: string) => { + validateSegment(s); + return getParts(s, url.pathname); + }); + return { + route: url.pathname, + component: '', + generate(_data: any): string { + return ''; + }, + params: Object.keys(params), + pattern: getPattern(segments, ASTRO_CONFIG_DEFAULTS.base, ASTRO_CONFIG_DEFAULTS.trailingSlash), + prerender: false, + segments, + type, + fallbackRoutes: [], + isIndex: false, + }; + } + + /** + * If the provided component isn't a default export, the function wraps it in an object `{default: Component }` to mimic the default export. + * @param componentFactory + * @param params + * @private + */ + #wrapComponent(componentFactory: AstroComponentFactory, params?: Record): ComponentInstance { + if (params) { + return { + default: componentFactory, + getStaticPaths() { + return [{ params }]; + } + } + } + return ({ default: componentFactory }) + } +} diff --git a/packages/astro/src/container/pipeline.ts b/packages/astro/src/container/pipeline.ts new file mode 100644 index 0000000000..5e76fad21d --- /dev/null +++ b/packages/astro/src/container/pipeline.ts @@ -0,0 +1,115 @@ +import { type HeadElements, Pipeline } from '../core/base-pipeline.js'; +import type { + ComponentInstance, + RewritePayload, + RouteData, + SSRElement, + SSRResult, +} from '../@types/astro.js'; +import { + createModuleScriptElement, + createStylesheetElementSet, +} from '../core/render/ssr-element.js'; +import { AstroError } from '../core/errors/index.js'; +import { RouteNotFound } from '../core/errors/errors-data.js'; +import type { SinglePageBuiltModule } from '../core/build/types.js'; + +export class ContainerPipeline extends Pipeline { + /** + * Internal cache to store components instances by `RouteData`. + * @private + */ + #componentsInterner: WeakMap = new WeakMap< + RouteData, + SinglePageBuiltModule + >(); + + static create({ + logger, + manifest, + renderers, + resolve, + serverLike, + streaming, + }: Pick< + ContainerPipeline, + 'logger' | 'manifest' | 'renderers' | 'resolve' | 'serverLike' | 'streaming' + >) { + return new ContainerPipeline( + logger, + manifest, + 'development', + renderers, + resolve, + serverLike, + streaming + ); + } + + componentMetadata(_routeData: RouteData): Promise | void {} + + headElements(routeData: RouteData): Promise | HeadElements { + const routeInfo = this.manifest.routes.find((route) => route.routeData === routeData); + const links = new Set(); + const scripts = new Set(); + const styles = createStylesheetElementSet(routeInfo?.styles ?? []); + + for (const script of routeInfo?.scripts ?? []) { + if ('stage' in script) { + if (script.stage === 'head-inline') { + scripts.add({ + props: {}, + children: script.children, + }); + } + } else { + scripts.add(createModuleScriptElement(script)); + } + } + return { links, styles, scripts }; + } + + async tryRewrite(rewritePayload: RewritePayload): Promise<[RouteData, ComponentInstance]> { + let foundRoute: RouteData | undefined; + // options.manifest is the actual type that contains the information + for (const route of this.manifest.routes) { + const routeData = route.routeData; + if (rewritePayload instanceof URL) { + if (routeData.pattern.test(rewritePayload.pathname)) { + foundRoute = routeData; + break; + } + } else if (rewritePayload instanceof Request) { + const url = new URL(rewritePayload.url); + if (routeData.pattern.test(url.pathname)) { + foundRoute = routeData; + break; + } + } else if (routeData.pattern.test(decodeURI(rewritePayload))) { + foundRoute = routeData; + break; + } + } + if (foundRoute) { + const componentInstance = await this.getComponentByRoute(foundRoute); + return [foundRoute, componentInstance]; + } else { + throw new AstroError(RouteNotFound); + } + } + + insertRoute(route: RouteData, componentInstance: ComponentInstance): void { + this.#componentsInterner.set(route, { + page() { + return Promise.resolve(componentInstance); + }, + renderers: this.manifest.renderers, + onRequest: this.manifest.middleware, + }); + } + + // At the moment it's not used by the container via any public API + // @ts-expect-error It needs to be implemented. + async getComponentByRoute(_routeData: RouteData): Promise { + } +} diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index ca795b91da..a6f0a46ec3 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -45,7 +45,7 @@ type RehypePlugin = ComplexifyWithUnion<_RehypePlugin>; type RemarkPlugin = ComplexifyWithUnion<_RemarkPlugin>; type RemarkRehype = ComplexifyWithOmit<_RemarkRehype>; -const ASTRO_CONFIG_DEFAULTS = { +export const ASTRO_CONFIG_DEFAULTS = { root: '.', srcDir: './src', publicDir: './public', diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 4a5cad1da5..d1abb8a160 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -91,7 +91,10 @@ export class RenderContext { * - endpoint * - fallback */ - async render(componentInstance: ComponentInstance | undefined): Promise { + async render( + componentInstance: ComponentInstance | undefined, + slots: Record = {} + ): Promise { const { cookies, middleware, pathname, pipeline } = this; const { logger, routeCache, serverLike, streaming } = pipeline; const props = await getProps({ @@ -148,7 +151,7 @@ export class RenderContext { result, componentInstance?.default as any, props, - {}, + slots, streaming, this.routeData ); diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 6c3010ad6f..4a36c8536d 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -48,7 +48,7 @@ function countOccurrences(needle: string, haystack: string) { const ROUTE_DYNAMIC_SPLIT = /\[(.+?\(.+?\)|.+?)\]/; const ROUTE_SPREAD = /^\.{3}.+$/; -function getParts(part: string, file: string) { +export function getParts(part: string, file: string) { const result: RoutePart[] = []; part.split(ROUTE_DYNAMIC_SPLIT).map((str, i) => { if (!str) return; @@ -70,12 +70,11 @@ function getParts(part: string, file: string) { return result; } -function getPattern( +export function getPattern( segments: RoutePart[][], - config: AstroConfig, + base: AstroConfig['base'], addTrailingSlash: AstroConfig['trailingSlash'] ) { - const base = config.base; const pathname = segments .map((segment) => { if (segment.length === 1 && segment[0].spread) { @@ -124,7 +123,7 @@ function getTrailingSlashPattern(addTrailingSlash: AstroConfig['trailingSlash']) return '\\/?$'; } -function validateSegment(segment: string, file = '') { +export function validateSegment(segment: string, file = '') { if (!file) file = segment; if (/\]\[/.test(segment)) { @@ -292,7 +291,7 @@ function createFileBasedRoutes( components.push(item.file); const component = item.file; const { trailingSlash } = settings.config; - const pattern = getPattern(segments, settings.config, trailingSlash); + const pattern = getPattern(segments, settings.config.base, trailingSlash); const generate = getRouteGenerator(segments, trailingSlash); const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join('/')}` @@ -363,7 +362,7 @@ function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): Pri const isPage = type === 'page'; const trailingSlash = isPage ? config.trailingSlash : 'never'; - const pattern = getPattern(segments, settings.config, trailingSlash); + const pattern = getPattern(segments, settings.config.base, trailingSlash); const generate = getRouteGenerator(segments, trailingSlash); const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join('/')}` @@ -419,7 +418,7 @@ function createRedirectRoutes( return getParts(s, from); }); - const pattern = getPattern(segments, settings.config, trailingSlash); + const pattern = getPattern(segments, settings.config.base, trailingSlash); const generate = getRouteGenerator(segments, trailingSlash); const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join('/')}` @@ -687,7 +686,7 @@ export function createRouteManifest( pathname, route, segments, - pattern: getPattern(segments, config, config.trailingSlash), + pattern: getPattern(segments, config.base, config.trailingSlash), type: 'fallback', }); } @@ -764,7 +763,7 @@ export function createRouteManifest( route, segments, generate, - pattern: getPattern(segments, config, config.trailingSlash), + pattern: getPattern(segments, config.base, config.trailingSlash), type: 'fallback', fallbackRoutes: [], }; diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 8d65940bf8..154944494f 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -115,7 +115,6 @@ export default function createVitePluginAstroServer({ * * Renderers needs to be pulled out from the page module emitted during the build. * @param settings - * @param renderers */ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest { let i18nManifest: SSRManifestI18n | undefined = undefined; diff --git a/packages/astro/test/container.test.js b/packages/astro/test/container.test.js new file mode 100644 index 0000000000..ab64efa9ff --- /dev/null +++ b/packages/astro/test/container.test.js @@ -0,0 +1,142 @@ +import { describe, it } from 'node:test'; +import { + Fragment, + createComponent, + maybeRenderHead, + render, + renderComponent, + renderHead, + renderSlot, +} from '../dist/runtime/server/index.js'; +import { experimental_AstroContainer } from '../dist/container/index.js'; +import assert from 'node:assert/strict'; + +const BaseLayout = createComponent((result, _props, slots) => { + return render` + + ${renderSlot(result, slots['head'])} + ${renderHead(result)} + + ${maybeRenderHead(result)} + + ${renderSlot(result, slots['default'])} + +`; +}); + +describe('Container', () => { + it('Renders a div with hello world text', async () => { + const Page = createComponent((result) => { + return render`${renderComponent( + result, + 'BaseLayout', + BaseLayout, + {}, + { + default: () => render`${maybeRenderHead(result)}
hello world
`, + head: () => render` + ${renderComponent( + result, + 'Fragment', + Fragment, + { slot: 'head' }, + { + default: () => render``, + } + )} + `, + } + )}`; + }); + + const container = await experimental_AstroContainer.create(); + const response = await container.renderToString(Page); + + assert.match(response, /hello world/); + }); + + it('Renders a slot', async () => { + const Page = createComponent( + (result, _props, slots) => { + return render`${renderComponent( + result, + 'BaseLayout', + BaseLayout, + {}, + { + default: () => render` + ${maybeRenderHead(result)} + ${renderSlot(result, slots['default'])} + `, + head: () => render` + ${renderComponent( + result, + 'Fragment', + Fragment, + { slot: 'head' }, + { + default: () => render``, + } + )} + `, + } + )}`; + }, + 'Component2.astro', + undefined + ); + + const container = await experimental_AstroContainer.create(); + const result = await container.renderToString(Page, { + slots: { + default: 'some slot', + }, + }); + + assert.match(result, /some slot/); + }); + + it('Renders multiple named slots', async () => { + const Page = createComponent( + (result, _props, slots) => { + return render`${renderComponent( + result, + 'BaseLayout', + BaseLayout, + {}, + { + default: () => render` + ${maybeRenderHead(result)} + ${renderSlot(result, slots['custom-name'])} + ${renderSlot(result, slots['foo-name'])} + `, + head: () => render` + ${renderComponent( + result, + 'Fragment', + Fragment, + { slot: 'head' }, + { + default: () => render``, + } + )} + `, + } + )}`; + }, + 'Component2.astro', + undefined + ); + + const container = await experimental_AstroContainer.create(); + const result = await container.renderToString(Page, { + slots: { + 'custom-name': 'Custom name', + 'foo-name': 'Bar name', + }, + }); + + assert.match(result, /Custom name/); + assert.match(result, /Bar name/); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 829ec7013e..4676f88cd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,31 @@ importers: specifier: ^4.8.7 version: link:../../packages/astro + examples/container-with-vitest: + dependencies: + '@astrojs/react': + specifier: ^3.3.4 + version: link:../../packages/integrations/react + astro: + specifier: experimental--container + version: link:../../packages/astro + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@18.19.31) + devDependencies: + '@types/react': + specifier: ^18.3.2 + version: 18.3.2 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.0 + examples/framework-alpine: dependencies: '@astrojs/alpinejs': @@ -10149,6 +10174,7 @@ packages: /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + requiresBuild: true dependencies: color-name: 1.1.4 @@ -15431,7 +15457,7 @@ packages: dependencies: color: 4.2.3 detect-libc: 2.0.3 - semver: 7.6.0 + semver: 7.6.2 optionalDependencies: '@img/sharp-darwin-arm64': 0.33.3 '@img/sharp-darwin-x64': 0.33.3 @@ -16246,6 +16272,7 @@ packages: /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + requiresBuild: true /tty-table@4.2.3: resolution: {integrity: sha512-Fs15mu0vGzCrj8fmJNP7Ynxt5J7praPXqFN0leZeZBXJwkMxv9cb2D454k1ltrtUSJbZ4yH4e0CynsHLxmUfFA==}