0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

feat: container APIs (#11051)

* feat: container APIs

* chore: handle runtime mode

* chore: render slots

* more prototyping

* Adding a changeset

* fix some weirdness around types

* feat: allow to inject the manifest

* feat: accept a manifest

* more APIs

* add `route` to the options

* chore

* fix component instance

* chore: document stuff

* remove commented code

* chore: add test for renderers and fixed its types

* fix: update name of the example

* fix regression inside tests

* use `experimental_`

* remove errors

* need to understand the types here

* remove some options that I don't deem necessary for this phase

* remove superfluous comments

* chore: remove useless `@ts-ignore` directive

* chore: address feedback

* fix regression and remove astro config

* chore: fix regression

* Apply suggestions from code review

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* ooops

* restore changes

---------

Co-authored-by: Matthew Phillips <matthew@skypack.dev>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Emanuele Stoppa 2024-05-22 12:11:26 +01:00 committed by GitHub
parent a6916e4402
commit 12a1bccc81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 969 additions and 15 deletions

View file

@ -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).

View file

@ -0,0 +1 @@
FROM node:18-bullseye

View file

@ -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/

View file

@ -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.

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import react from "@astrojs/react"
// https://astro.build/config
export default defineConfig({
integrations: [react()]
});

View file

@ -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"
}
}

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View file

@ -0,0 +1,7 @@
---
---
<div>
This is a card
<slot />
</div>

View file

@ -0,0 +1,14 @@
import { useState } from 'react';
export default function({ initialCount }) {
const [count, setCount] = useState(initialCount || 0);
return (
<div className="rounded-t-lg overflow-hidden border-t border-l border-r border-gray-400 text-center p-4">
<h2 className="font-semibold text-lg">Counter</h2>
<h3 className="font-medium text-lg">Count: {count}</h3>
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}

View file

@ -0,0 +1,5 @@
---
import Counter from './Counter.jsx';
---
<Counter initialCount={5} />

View file

@ -0,0 +1,22 @@
---
export function getStaticPaths() {
return [
{params: {locale: 'en'}},
];
}
const { locale } = Astro.params
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<h1>Astro</h1>
<p>Locale: {locale}</p>
</body>
</html>

View file

@ -0,0 +1,11 @@
export function GET() {
const json = {
foo: 'bar',
number: 1,
};
return new Response(JSON.stringify(json), {
headers: {
'content-type': 'application/json',
},
});
}

View file

@ -0,0 +1,16 @@
---
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<h1>Astro</h1>
</body>
</html>

View file

@ -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');
});

View file

@ -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');
});

View file

@ -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');
});

View file

@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/base"
}

View file

@ -0,0 +1,9 @@
/// <reference types="vitest" />
import { getViteConfig } from 'astro/config';
export default getViteConfig({
test: {
/* for example, use global to avoid globals imports (describe, test, expect): */
// globals: true,
},
});

View file

@ -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/*",

View file

@ -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<string, any>;
/**
* 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<string, string | undefined>;
/**
* 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<AstroUserConfig, 'integrations' | 'adapter' >
/**
* 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<string> {
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<experimental_AstroContainer> {
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<experimental_AstroContainer> {
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<string, string | undefined>;
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<string> {
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<Response> {
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<string, string | undefined>, 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<string, string | undefined>): ComponentInstance {
if (params) {
return {
default: componentFactory,
getStaticPaths() {
return [{ params }];
}
}
}
return ({ default: componentFactory })
}
}

View file

@ -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<RouteData, SinglePageBuiltModule> = 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<SSRResult['componentMetadata']> | void {}
headElements(routeData: RouteData): Promise<HeadElements> | HeadElements {
const routeInfo = this.manifest.routes.find((route) => route.routeData === routeData);
const links = new Set<never>();
const scripts = new Set<SSRElement>();
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<ComponentInstance> {
}
}

View file

@ -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',

View file

@ -91,7 +91,10 @@ export class RenderContext {
* - endpoint
* - fallback
*/
async render(componentInstance: ComponentInstance | undefined): Promise<Response> {
async render(
componentInstance: ComponentInstance | undefined,
slots: Record<string, any> = {}
): Promise<Response> {
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
);

View file

@ -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: [],
};

View file

@ -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;

View file

@ -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`<html>
<head>
${renderSlot(result, slots['head'])}
${renderHead(result)}
</head>
${maybeRenderHead(result)}
<body>
${renderSlot(result, slots['default'])}
</body>
</html>`;
});
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)}<div>hello world</div>`,
head: () => render`
${renderComponent(
result,
'Fragment',
Fragment,
{ slot: 'head' },
{
default: () => render`<meta charset="utf-8">`,
}
)}
`,
}
)}`;
});
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`<meta charset="utf-8">`,
}
)}
`,
}
)}`;
},
'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`<meta charset="utf-8">`,
}
)}
`,
}
)}`;
},
'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/);
});
});

View file

@ -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==}