From 9e1dd79ea76f293147018509793c2fcd74bfd29b Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 24 Feb 2026 10:16:09 -0500 Subject: [PATCH 01/10] Refactor router and migrate tests (#15510) * refactor: add router unit tests * fix: allow preserve build format in router * test: remove routing integration fixtures * Avoid an extra loop * PR comments * Full refactor * fixes breaking test * fix tests failing * fix tests again * Fix router match with base-stripped SSR paths * fix shiki lazy language test * Redirect base path in router match * Collapse double-slash redirects in router * Honor pageExtensions in parseRoute * Move buildOutput defaulting to callers * Ensure default 404 before router init * Use internal fileExtension helper in parseRoute * Use at(-1) for last route segment * Document extension stripping in parseRoute * Replace routing README with JSDoc * Broaden router redirect status type * Return base variable in normalizeBase * Return input value for root slash * Reuse path helpers in router * Type buildFormat from AstroConfig * Add JSDoc to routing test helpers * Fix prerender utils import paths * fix int * Add comment explaining why the routes are sorted --- packages/astro/src/assets/endpoint/config.ts | 36 +- packages/astro/src/config/index.ts | 6 +- packages/astro/src/container/index.ts | 6 +- packages/astro/src/core/app/base.ts | 17 +- packages/astro/src/core/build/index.ts | 4 +- packages/astro/src/core/build/static-build.ts | 2 +- packages/astro/src/core/dev/container.ts | 9 +- packages/astro/src/core/preview/index.ts | 4 +- packages/astro/src/core/redirects/render.ts | 2 +- packages/astro/src/core/render/paginate.ts | 2 +- packages/astro/src/core/routing/3xx.ts | 3 + .../routing/astro-designed-error-pages.ts | 39 +- .../create.ts => create-manifest.ts} | 248 ++++++++-- packages/astro/src/core/routing/default.ts | 2 +- .../core/routing/{manifest => }/generator.ts | 4 +- packages/astro/src/core/routing/helpers.ts | 11 +- packages/astro/src/core/routing/index.ts | 2 - .../internal/astro-designed-error-pages.ts | 38 ++ .../src/core/routing/internal/route-errors.ts | 10 + .../src/core/routing/internal/validation.ts | 16 + packages/astro/src/core/routing/match.ts | 12 +- packages/astro/src/core/routing/params.ts | 4 +- .../astro/src/core/routing/parse-route.ts | 106 +++++ .../src/core/routing/{manifest => }/parts.ts | 2 +- .../core/routing/{manifest => }/pattern.ts | 4 +- .../core/routing/{manifest => }/prerender.ts | 10 +- packages/astro/src/core/routing/rewrite.ts | 2 +- packages/astro/src/core/routing/router.ts | 192 ++++++++ .../core/routing/{manifest => }/segment.ts | 0 packages/astro/src/core/routing/validation.ts | 15 - .../astro/src/core/server-islands/endpoint.ts | 2 +- packages/astro/src/core/sync/index.ts | 4 +- packages/astro/src/integrations/hooks.ts | 2 +- packages/astro/src/prerender/routing.ts | 2 +- .../src/runtime/prerender/static-paths.ts | 2 +- .../astro/src/vite-plugin-app/pipeline.ts | 2 +- packages/astro/src/vite-plugin-pages/page.ts | 2 +- packages/astro/src/vite-plugin-pages/pages.ts | 2 +- .../astro/src/vite-plugin-routes/index.ts | 4 +- .../astro/test/astro-markdown-shiki.test.js | 2 +- packages/astro/test/dev-routing.test.js | 414 ----------------- .../test/dynamic-route-collision.test.js | 71 --- .../dynamic-route-collision/package.json | 8 - .../src/pages/[...slug].astro | 39 -- .../src/pages/[aOrder].astro | 27 -- .../src/pages/[bOrder].astro | 27 -- .../src/pages/[locale]/[...page].astro | 28 -- .../src/pages/[locale]/index.astro | 27 -- .../src/pages/[page].astro | 35 -- .../src/pages/about.astro | 8 - .../src/pages/index.astro | 8 - .../src/pages/tags/[...page].astro | 27 -- .../src/pages/tags/index.astro | 8 - .../src/pages/test.astro | 8 - .../src/pages/test/ing.astro | 8 - .../src/pages/who.astro | 8 - .../fixtures/route-manifest/basic/about.astro | 0 .../route-manifest/basic/blog/[slug].astro | 0 .../route-manifest/basic/blog/index.astro | 0 .../fixtures/route-manifest/basic/index.astro | 0 .../fixtures/route-manifest/encoding/#.astro | 0 .../hidden-dot/.unknown/foo.txt.js | 0 .../hidden-dot/.well-known/dnt-policy.astro | 0 .../route-manifest/hidden-underscore/_foo.js | 0 .../hidden-underscore/a/_b/c/d.js | 0 .../hidden-underscore/e/f/g/h.astro | 0 .../route-manifest/hidden-underscore/i/_j.js | 0 .../hidden-underscore/index.astro | 0 .../invalid-extension/about.astro | 0 .../invalid-extension/image.svg | 0 .../invalid-extension/index.astro | 0 .../invalid-extension/styles.css | 0 .../invalid-params/[foo][bar].astro | 0 .../invalid-rest/foo-[...rest]-bar.astro | 0 .../route-manifest/lockfiles/foo.astro | 0 .../route-manifest/lockfiles/foo.astro_tmp | 0 .../multiple-slugs/[file].[ext].astro | 0 .../test/fixtures/route-manifest/package.json | 8 - .../sorting/[...rest]/abc.astro | 0 .../[...rest]/deep/[...deep_rest]/index.astro | 0 .../[...rest]/deep/[...deep_rest]/xyz.astro | 0 .../sorting/[...rest]/deep/index.astro | 0 .../sorting/[...rest]/index.astro | 0 .../route-manifest/sorting/[wildcard].astro | 0 .../route-manifest/sorting/_layout.astro | 0 .../route-manifest/sorting/about.astro | 0 .../route-manifest/sorting/index.astro | 0 .../route-manifest/sorting/post/[id].astro | 0 .../sorting/post/_default.astro | 0 .../route-manifest/sorting/post/bar.astro | 0 .../route-manifest/sorting/post/f[xx].astro | 0 .../route-manifest/sorting/post/f[yy].astro | 0 .../route-manifest/sorting/post/foo.astro | 0 .../route-manifest/sorting/post/index.astro | 0 .../routing-priority/astro.config.mjs | 7 - .../fixtures/routing-priority/integration.mjs | 21 - .../fixtures/routing-priority/package.json | 8 - .../fixtures/routing-priority/src/[id].astro | 21 - .../routing-priority/src/_to-inject.astro | 12 - .../src/pages/[lang]/[...catchall].astro | 23 - .../src/pages/[lang]/index.astro | 23 - .../routing-priority/src/pages/[page].astro | 27 -- .../routing-priority/src/pages/[slug].astro | 27 -- .../src/pages/api/catch/[...slug].json.ts | 13 - .../src/pages/api/catch/[foo]-[bar].json.ts | 14 - .../routing-priority/src/pages/de/index.astro | 14 - .../src/pages/empty-paths/[...slug].astro | 18 - .../src/pages/empty-slug/[...slug].astro | 22 - .../routing-priority/src/pages/index.astro | 12 - .../src/pages/posts/[...slug].astro | 24 - .../src/pages/posts/[pid].astro | 23 - .../routing-priority/src/to-inject.astro | 12 - .../fixtures/user-route-priority/package.json | 8 - .../user-route-priority/public/favicon.ico | Bin 4286 -> 0 bytes .../src/pages/[number].astro | 12 - .../fixtures/virtual-routes/astro.config.mjs | 6 - .../test/fixtures/virtual-routes/package.json | 7 - .../fixtures/virtual-routes/src/middleware.js | 8 - packages/astro/test/preview-routing.test.js | 439 ------------------ packages/astro/test/route-manifest.test.js | 241 ---------- packages/astro/test/routing-priority.test.js | 270 ----------- .../test/units/routing/dev-routing.test.js | 71 +++ .../routing/dynamic-route-collision.test.js | 82 ++++ .../test/units/routing/generator.test.js | 2 +- .../astro/test/units/routing/manifest.test.js | 2 +- .../test/units/routing/parse-route.test.js | 24 + .../units/routing/preview-routing.test.js | 75 +++ .../test/units/routing/route-manifest.test.js | 284 +++++++++++ .../test/units/routing/route-matching.test.js | 4 +- .../test/units/routing/router-match.test.js | 320 +++++++++++++ .../units/routing/routing-priority.test.js | 187 ++++++++ .../astro/test/units/routing/test-helpers.js | 58 +++ .../test/units/routing/virtual-routes.test.js | 30 ++ .../astro/test/user-route-priority.test.js | 40 -- packages/astro/test/virtual-routes.test.js | 30 -- pnpm-lock.yaml | 30 -- 136 files changed, 1790 insertions(+), 2352 deletions(-) rename packages/astro/src/core/routing/{manifest/create.ts => create-manifest.ts} (79%) rename packages/astro/src/core/routing/{manifest => }/generator.ts (93%) delete mode 100644 packages/astro/src/core/routing/index.ts create mode 100644 packages/astro/src/core/routing/internal/astro-designed-error-pages.ts create mode 100644 packages/astro/src/core/routing/internal/route-errors.ts create mode 100644 packages/astro/src/core/routing/internal/validation.ts create mode 100644 packages/astro/src/core/routing/parse-route.ts rename packages/astro/src/core/routing/{manifest => }/parts.ts (92%) rename packages/astro/src/core/routing/{manifest => }/pattern.ts (90%) rename packages/astro/src/core/routing/{manifest => }/prerender.ts (69%) create mode 100644 packages/astro/src/core/routing/router.ts rename packages/astro/src/core/routing/{manifest => }/segment.ts (100%) delete mode 100644 packages/astro/test/dev-routing.test.js delete mode 100644 packages/astro/test/dynamic-route-collision.test.js delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/package.json delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/[...slug].astro delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/[aOrder].astro delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/[bOrder].astro delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/[locale]/[...page].astro delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/[locale]/index.astro delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/[page].astro delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/about.astro delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/index.astro delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/tags/[...page].astro delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/tags/index.astro delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/test.astro delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/test/ing.astro delete mode 100644 packages/astro/test/fixtures/dynamic-route-collision/src/pages/who.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/basic/about.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/basic/blog/[slug].astro delete mode 100644 packages/astro/test/fixtures/route-manifest/basic/blog/index.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/basic/index.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/encoding/#.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/hidden-dot/.unknown/foo.txt.js delete mode 100644 packages/astro/test/fixtures/route-manifest/hidden-dot/.well-known/dnt-policy.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/hidden-underscore/_foo.js delete mode 100644 packages/astro/test/fixtures/route-manifest/hidden-underscore/a/_b/c/d.js delete mode 100644 packages/astro/test/fixtures/route-manifest/hidden-underscore/e/f/g/h.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/hidden-underscore/i/_j.js delete mode 100644 packages/astro/test/fixtures/route-manifest/hidden-underscore/index.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/invalid-extension/about.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/invalid-extension/image.svg delete mode 100644 packages/astro/test/fixtures/route-manifest/invalid-extension/index.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/invalid-extension/styles.css delete mode 100644 packages/astro/test/fixtures/route-manifest/invalid-params/[foo][bar].astro delete mode 100644 packages/astro/test/fixtures/route-manifest/invalid-rest/foo-[...rest]-bar.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro_tmp delete mode 100644 packages/astro/test/fixtures/route-manifest/multiple-slugs/[file].[ext].astro delete mode 100644 packages/astro/test/fixtures/route-manifest/package.json delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/[...rest]/abc.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/[...deep_rest]/index.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/[...deep_rest]/xyz.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/index.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/[...rest]/index.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/[wildcard].astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/_layout.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/about.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/index.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/post/[id].astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/post/_default.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/post/bar.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/post/f[xx].astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/post/f[yy].astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/post/foo.astro delete mode 100644 packages/astro/test/fixtures/route-manifest/sorting/post/index.astro delete mode 100644 packages/astro/test/fixtures/routing-priority/astro.config.mjs delete mode 100644 packages/astro/test/fixtures/routing-priority/integration.mjs delete mode 100644 packages/astro/test/fixtures/routing-priority/package.json delete mode 100644 packages/astro/test/fixtures/routing-priority/src/[id].astro delete mode 100644 packages/astro/test/fixtures/routing-priority/src/_to-inject.astro delete mode 100644 packages/astro/test/fixtures/routing-priority/src/pages/[lang]/[...catchall].astro delete mode 100644 packages/astro/test/fixtures/routing-priority/src/pages/[lang]/index.astro delete mode 100644 packages/astro/test/fixtures/routing-priority/src/pages/[page].astro delete mode 100644 packages/astro/test/fixtures/routing-priority/src/pages/[slug].astro delete mode 100644 packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[...slug].json.ts delete mode 100644 packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[foo]-[bar].json.ts delete mode 100644 packages/astro/test/fixtures/routing-priority/src/pages/de/index.astro delete mode 100644 packages/astro/test/fixtures/routing-priority/src/pages/empty-paths/[...slug].astro delete mode 100644 packages/astro/test/fixtures/routing-priority/src/pages/empty-slug/[...slug].astro delete mode 100644 packages/astro/test/fixtures/routing-priority/src/pages/index.astro delete mode 100644 packages/astro/test/fixtures/routing-priority/src/pages/posts/[...slug].astro delete mode 100644 packages/astro/test/fixtures/routing-priority/src/pages/posts/[pid].astro delete mode 100644 packages/astro/test/fixtures/routing-priority/src/to-inject.astro delete mode 100644 packages/astro/test/fixtures/user-route-priority/package.json delete mode 100644 packages/astro/test/fixtures/user-route-priority/public/favicon.ico delete mode 100644 packages/astro/test/fixtures/user-route-priority/src/pages/[number].astro delete mode 100644 packages/astro/test/fixtures/virtual-routes/astro.config.mjs delete mode 100644 packages/astro/test/fixtures/virtual-routes/package.json delete mode 100644 packages/astro/test/fixtures/virtual-routes/src/middleware.js delete mode 100644 packages/astro/test/preview-routing.test.js delete mode 100644 packages/astro/test/route-manifest.test.js delete mode 100644 packages/astro/test/routing-priority.test.js create mode 100644 packages/astro/test/units/routing/dev-routing.test.js create mode 100644 packages/astro/test/units/routing/dynamic-route-collision.test.js create mode 100644 packages/astro/test/units/routing/parse-route.test.js create mode 100644 packages/astro/test/units/routing/preview-routing.test.js create mode 100644 packages/astro/test/units/routing/route-manifest.test.js create mode 100644 packages/astro/test/units/routing/router-match.test.js create mode 100644 packages/astro/test/units/routing/routing-priority.test.js create mode 100644 packages/astro/test/units/routing/test-helpers.js create mode 100644 packages/astro/test/units/routing/virtual-routes.test.js delete mode 100644 packages/astro/test/user-route-priority.test.js delete mode 100644 packages/astro/test/virtual-routes.test.js diff --git a/packages/astro/src/assets/endpoint/config.ts b/packages/astro/src/assets/endpoint/config.ts index 2c95b9539a86..bda4bd12488a 100644 --- a/packages/astro/src/assets/endpoint/config.ts +++ b/packages/astro/src/assets/endpoint/config.ts @@ -1,9 +1,5 @@ -import { - removeLeadingForwardSlash, - removeTrailingForwardSlash, -} from '@astrojs/internal-helpers/path'; -import { resolveInjectedRoute } from '../../core/routing/manifest/create.js'; -import { getPattern } from '../../core/routing/manifest/pattern.js'; +import { resolveInjectedRoute } from '../../core/routing/create-manifest.js'; +import { parseRoute } from '../../core/routing/parse-route.js'; import type { AstroSettings, RoutesList } from '../../types/astro.js'; import type { RouteData } from '../../types/public/internal.js'; @@ -28,30 +24,14 @@ function getImageEndpointData( : 'astro/assets/endpoint/generic' : settings.config.image.endpoint.entrypoint; - const segments = [ - [ - { - content: removeTrailingForwardSlash( - removeLeadingForwardSlash(settings.config.image.endpoint.route), - ), - dynamic: false, - spread: false, - }, - ], - ]; + const component = resolveInjectedRoute(endpointEntrypoint, settings.config.root, cwd).component; - return { + return parseRoute(settings.config.image.endpoint.route, settings, { + component, type: 'endpoint', + origin: 'internal', isIndex: false, - route: settings.config.image.endpoint.route, - pattern: getPattern(segments, settings.config.base, settings.config.trailingSlash), - segments, - params: [], - component: resolveInjectedRoute(endpointEntrypoint, settings.config.root, cwd).component, - pathname: settings.config.image.endpoint.route, prerender: false, - fallbackRoutes: [], - origin: 'internal', - distURL: [], - }; + params: [], + }); } diff --git a/packages/astro/src/config/index.ts b/packages/astro/src/config/index.ts index 0f7eba7c3ac0..a6d880969beb 100644 --- a/packages/astro/src/config/index.ts +++ b/packages/astro/src/config/index.ts @@ -1,6 +1,7 @@ import type { UserConfig as ViteUserConfig, UserConfigFn as ViteUserConfigFn } from 'vite'; import type { FontProvider } from '../assets/fonts/types.js'; -import { createRoutesList } from '../core/routing/manifest/create.js'; +import { createRoutesList } from '../core/routing/create-manifest.js'; +import { getPrerenderDefault } from '../prerender/utils.js'; import type { SessionDriverConfig, SessionDriverName } from '../core/session/types.js'; import type { AstroInlineConfig, AstroUserConfig, Locales } from '../types/public/config.js'; @@ -51,8 +52,9 @@ export function getViteConfig( settings, }, logger, - { dev: true, skipBuildOutputAssignment: false }, + { dev: true }, ); + settings.buildOutput = getPrerenderDefault(settings.config) ? 'static' : 'server'; const viteConfig = await createVite( {}, { routesList, settings, command: cmd, logger, mode, sync: false }, diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index e4faa334965c..44313507c065 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -8,9 +8,9 @@ import { nodeLogDestination } from '../core/logger/node.js'; import { NOOP_MIDDLEWARE_FN } from '../core/middleware/noop-middleware.js'; import { removeLeadingForwardSlash } from '../core/path.js'; import { RenderContext } from '../core/render-context.js'; -import { getParts } from '../core/routing/manifest/parts.js'; -import { getPattern } from '../core/routing/manifest/pattern.js'; -import { validateSegment } from '../core/routing/manifest/segment.js'; +import { getParts } from '../core/routing/parts.js'; +import { getPattern } from '../core/routing/pattern.js'; +import { validateSegment } from '../core/routing/segment.js'; import type { AstroComponentFactory } from '../runtime/server/index.js'; import { SlotString } from '../runtime/server/render/slot.js'; import type { ComponentInstance } from '../types/astro.js'; diff --git a/packages/astro/src/core/app/base.ts b/packages/astro/src/core/app/base.ts index 3169e359b7e5..bb263aa19407 100644 --- a/packages/astro/src/core/app/base.ts +++ b/packages/astro/src/core/app/base.ts @@ -28,6 +28,7 @@ import { type CreateRenderContext, RenderContext } from '../render-context.js'; import { redirectTemplate } from '../routing/3xx.js'; import { ensure404Route } from '../routing/astro-designed-error-pages.js'; import { matchRoute } from '../routing/match.js'; +import { Router } from '../routing/router.js'; import { type AstroSession, PERSIST_SYMBOL } from '../session/runtime.js'; import type { AppPipeline } from './pipeline.js'; import type { SSRManifest } from './types.js'; @@ -115,6 +116,7 @@ export abstract class BaseApp

{ adapterLogger: AstroIntegrationLogger; baseWithoutTrailingSlash: string; logger: Logger; + #router: Router; constructor(manifest: SSRManifest, streaming = true, ...args: any[]) { this.manifest = manifest; this.manifestData = { routes: manifest.routes.map((route) => route.routeData) }; @@ -128,6 +130,7 @@ export abstract class BaseApp

{ // This is necessary to allow running middlewares for 404 in SSR. There's special handling // to return the host 404 if the user doesn't provide a custom 404 ensure404Route(this.manifestData); + this.#router = this.createRouter(this.manifestData); } public abstract isDev(): boolean; @@ -180,6 +183,7 @@ export abstract class BaseApp

{ set setManifestData(newManifestData: RoutesList) { this.manifestData = newManifestData; + this.#router = this.createRouter(this.manifestData); } public removeBase(pathname: string) { @@ -222,8 +226,9 @@ export abstract class BaseApp

{ if (!pathname) { pathname = prependForwardSlash(this.removeBase(url.pathname)); } - let routeData = matchRoute(decodeURI(pathname), this.manifestData); - if (!routeData) return undefined; + const match = this.#router.match(decodeURI(pathname), { allowWithoutBase: true }); + if (match.type !== 'match') return undefined; + const routeData = match.route; if (allowPrerenderedRoutes) { return routeData; } @@ -234,6 +239,14 @@ export abstract class BaseApp

{ return routeData; } + private createRouter(manifestData: RoutesList): Router { + return new Router(manifestData.routes, { + base: this.manifest.base, + trailingSlash: this.manifest.trailingSlash, + buildFormat: this.manifest.buildFormat, + }); + } + /** * A matching route function to use in the development server. * Contrary to the `.match` function, this function resolves props and params, returning the correct diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 8842c4bbaee5..a1b0e258592a 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -21,7 +21,8 @@ import { createKey, getEnvironmentKey, hasEnvironmentKey } from '../encryption.j import { AstroError, AstroErrorData } from '../errors/index.js'; import type { Logger } from '../logger/core.js'; import { levels, timerMessage } from '../logger/core.js'; -import { createRoutesList } from '../routing/manifest/create.js'; +import { createRoutesList } from '../routing/create-manifest.js'; +import { getPrerenderDefault } from '../../prerender/utils.js'; import { clearContentLayerCache } from '../sync/index.js'; import { ensureProcessNodeEnv } from '../util.js'; import { collectPagesData } from './page-data.js'; @@ -123,6 +124,7 @@ class AstroBuilder { command: 'build', logger: logger, }); + this.settings.buildOutput = getPrerenderDefault(this.settings.config) ? 'static' : 'server'; this.routesList = await createRoutesList({ settings: this.settings }, this.logger); await runHookConfigDone({ settings: this.settings, logger: logger, command: 'build' }); diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 9fe2fbdb8149..01b4e8f77b25 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -14,7 +14,7 @@ import { getClientOutputDirectory, getServerOutputDirectory } from '../../preren import type { RouteData } from '../../types/public/internal.js'; import { VIRTUAL_PAGE_RESOLVED_MODULE_ID } from '../../vite-plugin-pages/const.js'; import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; -import { routeIsRedirect } from '../routing/index.js'; +import { routeIsRedirect } from '../routing/helpers.js'; import { getOutDirWithinCwd } from './common.js'; import { CHUNKS_PATH } from './consts.js'; import { generatePages } from './generate.js'; diff --git a/packages/astro/src/core/dev/container.ts b/packages/astro/src/core/dev/container.ts index c92945571876..46ff4fe47960 100644 --- a/packages/astro/src/core/dev/container.ts +++ b/packages/astro/src/core/dev/container.ts @@ -12,7 +12,8 @@ import type { AstroSettings } from '../../types/astro.js'; import type { AstroInlineConfig } from '../../types/public/config.js'; import { createVite } from '../create-vite.js'; import type { Logger } from '../logger/core.js'; -import { createRoutesList } from '../routing/manifest/create.js'; +import { createRoutesList } from '../routing/create-manifest.js'; +import { getPrerenderDefault } from '../../prerender/utils.js'; import { syncInternal } from '../sync/index.js'; import { warnMissingAdapter } from './adapter-validation.js'; @@ -91,10 +92,12 @@ export async function createContainer({ logger, { dev: true, - // If the adapter explicitly set a buildOutput, don't override it - skipBuildOutputAssignment: !!settings.adapter?.adapterFeatures?.buildOutput, }, ); + // If the adapter explicitly set a buildOutput, don't override it + if (!settings.adapter?.adapterFeatures?.buildOutput) { + settings.buildOutput = getPrerenderDefault(settings.config) ? 'static' : 'server'; + } const viteConfig = await createVite( { server: { host, headers, open, allowedHosts }, diff --git a/packages/astro/src/core/preview/index.ts b/packages/astro/src/core/preview/index.ts index c572510e80fe..1a40977e84b0 100644 --- a/packages/astro/src/core/preview/index.ts +++ b/packages/astro/src/core/preview/index.ts @@ -10,7 +10,8 @@ import type { PreviewModule, PreviewServer } from '../../types/public/preview.js import { resolveConfig } from '../config/config.js'; import { createNodeLogger } from '../logger/node.js'; import { createSettings } from '../config/settings.js'; -import { createRoutesList } from '../routing/manifest/create.js'; +import { createRoutesList } from '../routing/create-manifest.js'; +import { getPrerenderDefault } from '../../prerender/utils.js'; import { ensureProcessNodeEnv } from '../util.js'; import createStaticPreviewServer from './static-preview-server.js'; import { getResolvedHostForHttpServer } from './util.js'; @@ -41,6 +42,7 @@ export default async function preview(inlineConfig: AstroInlineConfig): Promise< // Create a route manifest so we can know if the build output is a static site or not await createRoutesList({ settings: settings, cwd: inlineConfig.root }, logger); + settings.buildOutput = getPrerenderDefault(settings.config) ? 'static' : 'server'; await runHookConfigDone({ settings: settings, logger: logger, command: 'preview' }); diff --git a/packages/astro/src/core/redirects/render.ts b/packages/astro/src/core/redirects/render.ts index a393776d19d5..eba4395b36ca 100644 --- a/packages/astro/src/core/redirects/render.ts +++ b/packages/astro/src/core/redirects/render.ts @@ -1,6 +1,6 @@ import type { RedirectConfig } from '../../types/public/index.js'; import type { RenderContext } from '../render-context.js'; -import { getRouteGenerator } from '../routing/manifest/generator.js'; +import { getRouteGenerator } from '../routing/generator.js'; export function redirectIsExternal(redirect: RedirectConfig): boolean { if (typeof redirect === 'string') { diff --git a/packages/astro/src/core/render/paginate.ts b/packages/astro/src/core/render/paginate.ts index 57f1718903cd..11f5ec2c2a06 100644 --- a/packages/astro/src/core/render/paginate.ts +++ b/packages/astro/src/core/render/paginate.ts @@ -9,7 +9,7 @@ import type { AstroConfig } from '../../types/public/index.js'; import type { RouteData } from '../../types/public/internal.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { joinPaths } from '../path.js'; -import { getRouteGenerator } from '../routing/manifest/generator.js'; +import { getRouteGenerator } from '../routing/generator.js'; export function generatePaginateFunction( routeMatch: RouteData, diff --git a/packages/astro/src/core/routing/3xx.ts b/packages/astro/src/core/routing/3xx.ts index e2a48ae56530..d8b641766ddf 100644 --- a/packages/astro/src/core/routing/3xx.ts +++ b/packages/astro/src/core/routing/3xx.ts @@ -5,6 +5,9 @@ type RedirectTemplate = { relativeLocation: string; }; +/** + * Generates a minimal HTML redirect page used for SSR redirects. + */ export function redirectTemplate({ status, absoluteLocation, diff --git a/packages/astro/src/core/routing/astro-designed-error-pages.ts b/packages/astro/src/core/routing/astro-designed-error-pages.ts index 45431543f3d0..ff0de41782ba 100644 --- a/packages/astro/src/core/routing/astro-designed-error-pages.ts +++ b/packages/astro/src/core/routing/astro-designed-error-pages.ts @@ -1,22 +1,5 @@ -import notFoundTemplate from '../../template/4xx.js'; -import type { ComponentInstance, RoutesList } from '../../types/astro.js'; -import type { RouteData } from '../../types/public/internal.js'; -import { DEFAULT_404_COMPONENT } from '../constants.js'; - -export const DEFAULT_404_ROUTE: RouteData = { - component: DEFAULT_404_COMPONENT, - params: [], - pattern: /^\/404\/?$/, - prerender: false, - pathname: '/404', - segments: [[{ content: '404', dynamic: false, spread: false }]], - type: 'page', - route: '/404', - fallbackRoutes: [], - isIndex: false, - origin: 'internal', - distURL: [], -}; +import type { RoutesList } from '../../types/astro.js'; +import { DEFAULT_404_ROUTE } from './internal/astro-designed-error-pages.js'; export function ensure404Route(manifest: RoutesList) { if (!manifest.routes.some((route) => route.route === '/404')) { @@ -24,21 +7,3 @@ export function ensure404Route(manifest: RoutesList) { } return manifest; } - -async function default404Page({ pathname }: { pathname: string }) { - return new Response( - notFoundTemplate({ - statusCode: 404, - title: 'Not found', - tabTitle: '404: Not Found', - pathname, - }), - { status: 404, headers: { 'Content-Type': 'text/html' } }, - ); -} -// mark the function as an AstroComponentFactory for the rendering internals -default404Page.isAstroComponentFactory = true; - -export const default404Instance: ComponentInstance = { - default: default404Page, -}; diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/create-manifest.ts similarity index 79% rename from packages/astro/src/core/routing/manifest/create.ts rename to packages/astro/src/core/routing/create-manifest.ts index 64d4d724348d..c0b614d47c71 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/create-manifest.ts @@ -4,26 +4,26 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import pLimit from 'p-limit'; import colors from 'piccolore'; -import { injectImageEndpoint } from '../../../assets/endpoint/config.js'; -import { runHookRoutesResolved } from '../../../integrations/hooks.js'; -import { getPrerenderDefault } from '../../../prerender/utils.js'; -import type { AstroSettings, RoutesList } from '../../../types/astro.js'; -import type { AstroConfig } from '../../../types/public/config.js'; -import type { RouteData, RoutePart } from '../../../types/public/internal.js'; -import { toRoutingStrategy } from '../../app/entrypoints/index.js'; -import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js'; +import { injectImageEndpoint } from '../../assets/endpoint/config.js'; +import { runHookRoutesResolved } from '../../integrations/hooks.js'; +import { getPrerenderDefault } from '../../prerender/utils.js'; +import type { AstroSettings, RoutesList } from '../../types/astro.js'; +import type { AstroConfig } from '../../types/public/config.js'; +import type { RouteData, RoutePart } from '../../types/public/internal.js'; +import { toRoutingStrategy } from '../app/entrypoints/index.js'; +import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../constants.js'; import { InvalidRedirectDestination, MissingIndexForInternationalization, UnsupportedExternalRedirect, -} from '../../errors/errors-data.js'; -import { AstroError } from '../../errors/index.js'; -import type { Logger } from '../../logger/core.js'; -import { hasFileExtension, removeLeadingForwardSlash, slash } from '../../path.js'; -import { injectServerIslandRoute } from '../../server-islands/endpoint.js'; -import { resolvePages } from '../../util.js'; -import { ensure404Route } from '../astro-designed-error-pages.js'; -import { routeComparator } from '../priority.js'; +} from '../errors/errors-data.js'; +import { AstroError } from '../errors/index.js'; +import type { Logger } from '../logger/core.js'; +import { hasFileExtension, removeLeadingForwardSlash, slash } from '../path.js'; +import { injectServerIslandRoute } from '../server-islands/endpoint.js'; +import { resolvePages } from '../util.js'; +import { ensure404Route } from './astro-designed-error-pages.js'; +import { routeComparator } from './priority.js'; import { getPattern } from './pattern.js'; import { getRoutePrerenderOption } from './prerender.js'; import { validateSegment } from './segment.js'; @@ -44,6 +44,11 @@ interface Item { const ROUTE_DYNAMIC_SPLIT = /\[([^[\]()]+(?:\([^)]+\))?)\]/; const ROUTE_SPREAD = /^\.{3}.+$/; +export interface RouteEntry { + path: string; + isDir: boolean; +} + function getParts(part: string, file: string) { const result: RoutePart[] = []; part.split(ROUTE_DYNAMIC_SPLIT).map((str, i) => { @@ -99,6 +104,11 @@ function isSemanticallyEqualSegment(segmentA: RoutePart[], segmentB: RoutePart[] return true; } +type RoutingSettings = Pick< + AstroSettings, + 'config' | 'injectedRoutes' | 'pageExtensions' | 'buildOutput' +>; + interface CreateRouteManifestParams { /** Astro Settings object */ settings: AstroSettings; @@ -112,7 +122,19 @@ function createFileBasedRoutes( { settings, cwd, fsMod }: CreateRouteManifestParams, logger: Logger, ): RouteData[] { - const components: string[] = []; + const { config } = settings; + const pages = resolvePages(config); + const localFs = fsMod ?? nodeFs; + const rootPath = fileURLToPath(config.root); + + if (!localFs.existsSync(pages)) { + if (settings.injectedRoutes.length === 0) { + const pagesDirRootRelative = pages.href.slice(settings.config.root.href.length); + logger.warn(null, `Missing pages directory: ${pagesDirRootRelative}`); + } + return []; + } + const routes: RouteData[] = []; const validPageExtensions = new Set([ '.astro', @@ -121,7 +143,6 @@ function createFileBasedRoutes( ]); const invalidPotentialPages = new Set(['.tsx', '.jsx', '.vue', '.svelte']); const validEndpointExtensions = new Set(['.js', '.ts']); - const localFs = fsMod ?? nodeFs; const prerender = getPrerenderDefault(settings.config); function walk( @@ -130,11 +151,11 @@ function createFileBasedRoutes( parentSegments: RoutePart[][], parentParams: string[], ) { - let items: Item[] = []; + const items: Item[] = []; const files = fs.readdirSync(dir); for (const basename of files) { const resolved = path.join(dir, basename); - const file = slash(path.relative(cwd || fileURLToPath(settings.config.root), resolved)); + const file = slash(path.relative(cwd || rootPath, resolved)); const isDir = fs.statSync(resolved).isDirectory(); const ext = path.extname(basename); @@ -145,10 +166,7 @@ function createFileBasedRoutes( if (basename[0] === '.' && basename !== '.well-known') { continue; } - // filter out "foo.astro_tmp" files, etc if (!isDir && !validPageExtensions.has(ext) && !validEndpointExtensions.has(ext)) { - // Only warn for files that could potentially be interpreted by users has being possible extensions for pages - // It's otherwise not a problem for users to have other files in their pages directory, for instance colocated images. if (invalidPotentialPages.has(ext)) { logger.warn( null, @@ -218,7 +236,6 @@ function createFileBasedRoutes( if (item.isDir) { walk(fsMod ?? fs, path.join(dir, item.basename), segments, params); } else { - components.push(item.file); const component = item.file; const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join('/')}` @@ -244,19 +261,171 @@ function createFileBasedRoutes( } } - const { config } = settings; - const pages = resolvePages(config); + walk(localFs, fileURLToPath(pages), [], []); + return routes; +} + +export function createRoutesFromEntries( + entries: RouteEntry[], + settings: RoutingSettings, + logger: Logger, + pagesDirRelative = 'src/pages', +): RouteData[] { + const entriesByDir = groupEntriesByDir(entries); + return createRoutesFromEntriesByDir(entriesByDir, settings, logger, pagesDirRelative); +} + +function createRoutesFromEntriesByDir( + entriesByDir: Map, + settings: RoutingSettings, + logger: Logger, + pagesDirRelative: string, +): RouteData[] { + const routes: RouteData[] = []; + const validPageExtensions = new Set([ + '.astro', + ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS, + ...settings.pageExtensions, + ]); + const invalidPotentialPages = new Set(['.tsx', '.jsx', '.vue', '.svelte']); + const validEndpointExtensions = new Set(['.js', '.ts']); + const prerender = getPrerenderDefault(settings.config); + + function walk(dir: string, parentSegments: RoutePart[][], parentParams: string[]) { + const items: Item[] = []; + const dirEntries = entriesByDir.get(dir) ?? []; + for (const entry of dirEntries) { + const basename = path.posix.basename(entry.path); + const ext = path.extname(basename); + const name = ext ? basename.slice(0, -ext.length) : basename; + const isDir = entry.isDir; + const file = slash(path.posix.join(pagesDirRelative, entry.path)); + + if (name[0] === '_') { + continue; + } + if (basename[0] === '.' && basename !== '.well-known') { + continue; + } + if (!isDir && !validPageExtensions.has(ext) && !validEndpointExtensions.has(ext)) { + if (invalidPotentialPages.has(ext)) { + logger.warn( + null, + `Unsupported file type ${colors.bold( + file, + )} found in pages directory. Only Astro files can be used as pages. Prefix filename with an underscore (\`_\`) to ignore this warning, or move the file outside of the pages directory.`, + ); + } + continue; + } + + const segment = isDir ? basename : name; + validateSegment(segment, file); + + const parts = getParts(segment, file); + const isIndex = isDir ? false : basename.substring(0, basename.lastIndexOf('.')) === 'index'; + const routeSuffix = basename.slice(basename.indexOf('.'), -ext.length); + const isPage = validPageExtensions.has(ext); + + items.push({ + basename, + ext, + parts, + file, + isDir, + isIndex, + isPage, + routeSuffix, + }); + } - if (localFs.existsSync(pages)) { - walk(localFs, fileURLToPath(pages), [], []); - } else if (settings.injectedRoutes.length === 0) { - const pagesDirRootRelative = pages.href.slice(settings.config.root.href.length); - logger.warn(null, `Missing pages directory: ${pagesDirRootRelative}`); + for (const item of items) { + const segments = parentSegments.slice(); + + if (item.isIndex) { + if (item.routeSuffix) { + if (segments.length > 0) { + const lastSegment = segments[segments.length - 1].slice(); + const lastPart = lastSegment[lastSegment.length - 1]; + + if (lastPart.dynamic) { + lastSegment.push({ + dynamic: false, + spread: false, + content: item.routeSuffix, + }); + } else { + lastSegment[lastSegment.length - 1] = { + dynamic: false, + spread: false, + content: `${lastPart.content}${item.routeSuffix}`, + }; + } + + segments[segments.length - 1] = lastSegment; + } else { + segments.push(item.parts); + } + } + } else { + segments.push(item.parts); + } + + const params = parentParams.slice(); + params.push(...item.parts.filter((p) => p.dynamic).map((p) => p.content)); + + if (item.isDir) { + walk(path.posix.join(dir, item.basename), segments, params); + } else { + const component = item.file; + const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) + ? `/${segments.map((segment) => segment[0].content).join('/')}` + : null; + const trailingSlash = trailingSlashForPath(pathname, settings.config); + const pattern = getPattern(segments, settings.config.base, trailingSlash); + const route = joinSegments(segments); + routes.push({ + route, + isIndex: item.isIndex, + type: item.isPage ? 'page' : 'endpoint', + pattern, + segments, + params, + component, + pathname: pathname || undefined, + prerender, + fallbackRoutes: [], + distURL: [], + origin: 'project', + }); + } + } } + walk('', [], []); return routes; } +function groupEntriesByDir(entries: RouteEntry[]): Map { + const entriesByDir = new Map(); + + for (const entry of entries) { + const normalizedPath = slash(entry.path); + const dir = path.posix.dirname(normalizedPath); + const key = dir === '.' ? '' : dir; + const list = entriesByDir.get(key); + const normalizedEntry = + normalizedPath === entry.path ? entry : { ...entry, path: normalizedPath }; + if (list) { + list.push(normalizedEntry); + } else { + entriesByDir.set(key, [normalizedEntry]); + } + } + + return entriesByDir; +} + // Get trailing slash rule for a path, based on the config and whether the path has an extension. const trailingSlashForPath = ( pathname: string | null, @@ -481,20 +650,16 @@ function detectRouteCollision(a: RouteData, b: RouteData, _config: AstroConfig, logger.warn('router', 'A collision will result in an hard error in following versions of Astro.'); } -/** Create manifest of all static routes */ +/** + * Create a full route manifest from filesystem and injected routes. + */ export async function createRoutesList( params: CreateRouteManifestParams, logger: Logger, { dev = false, - skipBuildOutputAssignment = false, }: { dev?: boolean; - /** - * When `true`, the assignment of `settings.buildOutput` is skipped. - * Usually, that's needed when this function has already been called. - */ - skipBuildOutputAssignment?: boolean; } = {}, ): Promise { const { settings } = params; @@ -524,10 +689,6 @@ export async function createRoutesList( ...[...filteredFiledBasedRoutes, ...injectedRoutes, ...redirectRoutes].sort(routeComparator), ]; - if (skipBuildOutputAssignment !== true) { - settings.buildOutput = getPrerenderDefault(config) ? 'static' : 'server'; - } - // Check the prerender option for each route const limit = pLimit(10); let promises = []; @@ -781,6 +942,9 @@ export async function createRoutesList( }; } +/** + * Resolve a route entrypoint to an absolute component path. + */ export function resolveInjectedRoute(entrypoint: string, root: URL, cwd?: string) { let resolved; try { diff --git a/packages/astro/src/core/routing/default.ts b/packages/astro/src/core/routing/default.ts index 52a838bd4fc8..1793231a4f53 100644 --- a/packages/astro/src/core/routing/default.ts +++ b/packages/astro/src/core/routing/default.ts @@ -6,7 +6,7 @@ import { SERVER_ISLAND_COMPONENT, SERVER_ISLAND_ROUTE, } from '../server-islands/endpoint.js'; -import { DEFAULT_404_ROUTE, default404Instance } from './astro-designed-error-pages.js'; +import { DEFAULT_404_ROUTE, default404Instance } from './internal/astro-designed-error-pages.js'; type DefaultRouteParams = { instance: ComponentInstance; diff --git a/packages/astro/src/core/routing/manifest/generator.ts b/packages/astro/src/core/routing/generator.ts similarity index 93% rename from packages/astro/src/core/routing/manifest/generator.ts rename to packages/astro/src/core/routing/generator.ts index 4ffbad8e3503..94dcf4324465 100644 --- a/packages/astro/src/core/routing/manifest/generator.ts +++ b/packages/astro/src/core/routing/generator.ts @@ -1,5 +1,5 @@ -import type { AstroConfig } from '../../../types/public/config.js'; -import type { RoutePart } from '../../../types/public/internal.js'; +import type { AstroConfig } from '../../types/public/config.js'; +import type { RoutePart } from '../../types/public/internal.js'; /** * Sanitizes the parameters object by normalizing string values and replacing certain characters with their URL-encoded equivalents. diff --git a/packages/astro/src/core/routing/helpers.ts b/packages/astro/src/core/routing/helpers.ts index b8b2978e7574..48a2bfa82c39 100644 --- a/packages/astro/src/core/routing/helpers.ts +++ b/packages/astro/src/core/routing/helpers.ts @@ -1,7 +1,7 @@ import type { RouteData } from '../../types/public/internal.js'; import type { RouteInfo } from '../app/types.js'; import type { RoutesList } from '../../types/astro.js'; -import { isRoute404, isRoute500 } from './match.js'; +import { isRoute404, isRoute500 } from './internal/route-errors.js'; type RedirectRouteData = RouteData & { redirect: string; @@ -16,6 +16,9 @@ export function routeIsRedirect(route: RouteData | undefined): route is Redirect return route?.type === 'redirect'; } +/** + * True if the route represents a fallback entry. + */ export function routeIsFallback(route: RouteData | undefined): boolean { return route?.type === 'fallback'; } @@ -46,10 +49,16 @@ export function getFallbackRoute(route: RouteData, routeList: RouteInfo[]): Rout return fallbackRoute.routeData; } +/** + * Return a user-provided 404 route if one exists. + */ export function getCustom404Route(manifestData: RoutesList): RouteData | undefined { return manifestData.routes.find((r) => isRoute404(r.route)); } +/** + * Return a user-provided 500 route if one exists. + */ export function getCustom500Route(manifestData: RoutesList): RouteData | undefined { return manifestData.routes.find((r) => isRoute500(r.route)); } diff --git a/packages/astro/src/core/routing/index.ts b/packages/astro/src/core/routing/index.ts deleted file mode 100644 index 234936aaac22..000000000000 --- a/packages/astro/src/core/routing/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { routeIsRedirect } from './helpers.js'; -export { matchAllRoutes } from './match.js'; diff --git a/packages/astro/src/core/routing/internal/astro-designed-error-pages.ts b/packages/astro/src/core/routing/internal/astro-designed-error-pages.ts new file mode 100644 index 000000000000..bc68281388ff --- /dev/null +++ b/packages/astro/src/core/routing/internal/astro-designed-error-pages.ts @@ -0,0 +1,38 @@ +import notFoundTemplate from '../../../template/4xx.js'; +import type { ComponentInstance } from '../../../types/astro.js'; +import type { RouteData } from '../../../types/public/internal.js'; +import { DEFAULT_404_COMPONENT } from '../../constants.js'; + +export const DEFAULT_404_ROUTE: RouteData = { + component: DEFAULT_404_COMPONENT, + params: [], + pattern: /^\/404\/?$/, + prerender: false, + pathname: '/404', + segments: [[{ content: '404', dynamic: false, spread: false }]], + type: 'page', + route: '/404', + fallbackRoutes: [], + isIndex: false, + origin: 'internal', + distURL: [], +}; + +async function default404Page({ pathname }: { pathname: string }) { + return new Response( + notFoundTemplate({ + statusCode: 404, + title: 'Not found', + tabTitle: '404: Not Found', + pathname, + }), + { status: 404, headers: { 'Content-Type': 'text/html' } }, + ); +} + +// mark the function as an AstroComponentFactory for the rendering internals +default404Page.isAstroComponentFactory = true; + +export const default404Instance: ComponentInstance = { + default: default404Page, +}; diff --git a/packages/astro/src/core/routing/internal/route-errors.ts b/packages/astro/src/core/routing/internal/route-errors.ts new file mode 100644 index 000000000000..23ca454068a0 --- /dev/null +++ b/packages/astro/src/core/routing/internal/route-errors.ts @@ -0,0 +1,10 @@ +const ROUTE404_RE = /^\/404\/?$/; +const ROUTE500_RE = /^\/500\/?$/; + +export function isRoute404(route: string) { + return ROUTE404_RE.test(route); +} + +export function isRoute500(route: string) { + return ROUTE500_RE.test(route); +} diff --git a/packages/astro/src/core/routing/internal/validation.ts b/packages/astro/src/core/routing/internal/validation.ts new file mode 100644 index 000000000000..658cde7f1590 --- /dev/null +++ b/packages/astro/src/core/routing/internal/validation.ts @@ -0,0 +1,16 @@ +import { AstroError, AstroErrorData } from '../../errors/index.js'; + +const VALID_PARAM_TYPES = ['string', 'undefined']; + +/** Throws error for invalid parameter in getStaticPaths() response */ +export function validateGetStaticPathsParameter([key, value]: [string, any], route: string) { + if (!VALID_PARAM_TYPES.includes(typeof value)) { + throw new AstroError({ + ...AstroErrorData.GetStaticPathsInvalidRouteParam, + message: AstroErrorData.GetStaticPathsInvalidRouteParam.message(key, value, typeof value), + location: { + file: route, + }, + }); + } +} diff --git a/packages/astro/src/core/routing/match.ts b/packages/astro/src/core/routing/match.ts index caa3ae743119..bc52c1ed8eaf 100644 --- a/packages/astro/src/core/routing/match.ts +++ b/packages/astro/src/core/routing/match.ts @@ -2,6 +2,7 @@ import type { RoutesList } from '../../types/astro.js'; import type { RouteData } from '../../types/public/internal.js'; import { redirectIsExternal } from '../redirects/render.js'; import { SERVER_ISLAND_BASE_PREFIX, SERVER_ISLAND_COMPONENT } from '../server-islands/endpoint.js'; +import { isRoute404, isRoute500 } from './internal/route-errors.js'; /** Find matching route from pathname */ export function matchRoute(pathname: string, manifest: RoutesList): RouteData | undefined { @@ -18,17 +19,6 @@ export function matchAllRoutes(pathname: string, manifest: RoutesList): RouteDat return manifest.routes.filter((route) => route.pattern.test(pathname)); } -const ROUTE404_RE = /^\/404\/?$/; -const ROUTE500_RE = /^\/500\/?$/; - -export function isRoute404(route: string) { - return ROUTE404_RE.test(route); -} - -export function isRoute500(route: string) { - return ROUTE500_RE.test(route); -} - /** * Determines if the given route matches a 404 or 500 error page. * diff --git a/packages/astro/src/core/routing/params.ts b/packages/astro/src/core/routing/params.ts index 67d313c86c04..0f9bae073cfb 100644 --- a/packages/astro/src/core/routing/params.ts +++ b/packages/astro/src/core/routing/params.ts @@ -2,8 +2,8 @@ import type { GetStaticPathsItem } from '../../types/public/common.js'; import type { AstroConfig } from '../../types/public/index.js'; import type { RouteData } from '../../types/public/internal.js'; import { trimSlashes } from '../path.js'; -import { getRouteGenerator } from './manifest/generator.js'; -import { validateGetStaticPathsParameter } from './validation.js'; +import { getRouteGenerator } from './generator.js'; +import { validateGetStaticPathsParameter } from './internal/validation.js'; /** * given a route's Params object, validate parameter diff --git a/packages/astro/src/core/routing/parse-route.ts b/packages/astro/src/core/routing/parse-route.ts new file mode 100644 index 000000000000..2dd3b0fe690f --- /dev/null +++ b/packages/astro/src/core/routing/parse-route.ts @@ -0,0 +1,106 @@ +import type { AstroSettings } from '../../types/astro.js'; +import type { RouteData, RoutePart } from '../../types/public/internal.js'; +import { fileExtension } from '@astrojs/internal-helpers/path'; +import { removeLeadingForwardSlash, removeTrailingForwardSlash } from '../path.js'; +import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../constants.js'; +import { getPattern } from './pattern.js'; +import { getParts } from './parts.js'; +import { validateSegment } from './segment.js'; + +/** + * Settings needed to parse a route path into RouteData. + */ +type ParseRouteConfig = Pick; + +/** + * Options for building the RouteData output. + */ +type ParseRouteOptions = { + component: string; + type?: RouteData['type']; + origin?: RouteData['origin']; + isIndex?: boolean; + prerender?: boolean; + params?: string[]; +}; + +const ROUTE_FILE_EXTENSIONS = new Set([ + '.astro', + '.md', + '.mdx', + '.markdown', + '.js', + '.ts', + ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS, +]); + +/** + * Parse a file path-like route into RouteData, respecting extensions and config. + */ +export function parseRoute( + route: string, + options: ParseRouteConfig, + parseOptions: ParseRouteOptions, +): RouteData { + const routeFileExtensions = options.pageExtensions?.length + ? new Set([...ROUTE_FILE_EXTENSIONS, ...options.pageExtensions]) + : ROUTE_FILE_EXTENSIONS; + const normalizedRoute = removeTrailingForwardSlash(removeLeadingForwardSlash(route)); + const segments: RoutePart[][] = []; + const rawSegments = normalizedRoute ? normalizedRoute.split('/') : []; + let isIndex = parseOptions.isIndex ?? false; + + if (rawSegments.length > 0) { + const last = rawSegments.at(-1)!; + const ext = fileExtension(last); + if (ext && routeFileExtensions.has(ext)) { + // Strip known file extensions and treat trailing /index.* as the parent route. + const base = last.slice(0, -ext.length); + rawSegments[rawSegments.length - 1] = base; + if (base === 'index') { + isIndex = true; + rawSegments.pop(); + } + } + } + + for (const segment of rawSegments) { + validateSegment(segment, route); + segments.push(getParts(segment, route)); + } + + const routePath = joinSegments(segments); + const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) + ? `/${segments.map((segment) => segment[0].content).join('/')}` + : null; + const params = + parseOptions.params ?? + segments + .flat() + .filter((part) => part.dynamic) + .map((part) => part.content); + + return { + route: routePath, + component: parseOptions.component, + params, + pathname: pathname || undefined, + pattern: getPattern(segments, options.config.base, options.config.trailingSlash), + segments, + type: parseOptions.type ?? 'page', + prerender: parseOptions.prerender ?? false, + fallbackRoutes: [], + distURL: [], + isIndex, + origin: parseOptions.origin ?? 'project', + }; +} + +function joinSegments(segments: RoutePart[][]): string { + if (segments.length === 0) return '/'; + const arr = segments.map((segment) => { + return segment.map((part) => (part.dynamic ? `[${part.content}]` : part.content)).join(''); + }); + + return `/${arr.join('/')}`.toLowerCase(); +} diff --git a/packages/astro/src/core/routing/manifest/parts.ts b/packages/astro/src/core/routing/parts.ts similarity index 92% rename from packages/astro/src/core/routing/manifest/parts.ts rename to packages/astro/src/core/routing/parts.ts index 77aa70b2b824..ffc07f7cc136 100644 --- a/packages/astro/src/core/routing/manifest/parts.ts +++ b/packages/astro/src/core/routing/parts.ts @@ -1,4 +1,4 @@ -import type { RoutePart } from '../../../types/public/index.js'; +import type { RoutePart } from '../../types/public/index.js'; // Disable eslint as we're not sure how to improve this regex yet // eslint-disable-next-line regexp/no-super-linear-backtracking diff --git a/packages/astro/src/core/routing/manifest/pattern.ts b/packages/astro/src/core/routing/pattern.ts similarity index 90% rename from packages/astro/src/core/routing/manifest/pattern.ts rename to packages/astro/src/core/routing/pattern.ts index 8a9a9d27f9fc..4af076489904 100644 --- a/packages/astro/src/core/routing/manifest/pattern.ts +++ b/packages/astro/src/core/routing/pattern.ts @@ -1,5 +1,5 @@ -import type { AstroConfig } from '../../../types/public/config.js'; -import type { RoutePart } from '../../../types/public/internal.js'; +import type { AstroConfig } from '../../types/public/config.js'; +import type { RoutePart } from '../../types/public/internal.js'; export function getPattern( segments: RoutePart[][], diff --git a/packages/astro/src/core/routing/manifest/prerender.ts b/packages/astro/src/core/routing/prerender.ts similarity index 69% rename from packages/astro/src/core/routing/manifest/prerender.ts rename to packages/astro/src/core/routing/prerender.ts index 9b5512d90297..8eb383fc631b 100644 --- a/packages/astro/src/core/routing/manifest/prerender.ts +++ b/packages/astro/src/core/routing/prerender.ts @@ -1,8 +1,8 @@ -import { runHookRouteSetup } from '../../../integrations/hooks.js'; -import { getPrerenderDefault } from '../../../prerender/utils.js'; -import type { AstroSettings } from '../../../types/astro.js'; -import type { RouteData } from '../../../types/public/internal.js'; -import type { Logger } from '../../logger/core.js'; +import { runHookRouteSetup } from '../../integrations/hooks.js'; +import { getPrerenderDefault } from '../../prerender/utils.js'; +import type { AstroSettings } from '../../types/astro.js'; +import type { RouteData } from '../../types/public/internal.js'; +import type { Logger } from '../logger/core.js'; const PRERENDER_REGEX = /^\s*export\s+const\s+prerender\s*=\s*(true|false);?/m; diff --git a/packages/astro/src/core/routing/rewrite.ts b/packages/astro/src/core/routing/rewrite.ts index d3b0aac04383..6851ecd920de 100644 --- a/packages/astro/src/core/routing/rewrite.ts +++ b/packages/astro/src/core/routing/rewrite.ts @@ -13,7 +13,7 @@ import { trimSlashes, } from '../path.js'; import { createRequest } from '../request.js'; -import { DEFAULT_404_ROUTE } from './astro-designed-error-pages.js'; +import { DEFAULT_404_ROUTE } from './internal/astro-designed-error-pages.js'; type FindRouteToRewrite = { payload: RewritePayload; diff --git a/packages/astro/src/core/routing/router.ts b/packages/astro/src/core/routing/router.ts new file mode 100644 index 000000000000..3577d8522ca7 --- /dev/null +++ b/packages/astro/src/core/routing/router.ts @@ -0,0 +1,192 @@ +import type { AstroConfig } from '../../types/public/config.js'; +import type { Params } from '../../types/public/common.js'; +import type { ValidRedirectStatus } from '../../types/public/config.js'; +import { prependForwardSlash, removeTrailingForwardSlash } from '../path.js'; +import type { RouteData } from '../../types/public/internal.js'; +import { getParams } from '../render/params-and-props.js'; +import { routeComparator } from './priority.js'; + +/** + * Router options derived from the active Astro config. + * Controls base matching, trailing slash handling, and build output format. + */ +export interface RouterOptions { + base: AstroConfig['base']; + trailingSlash: AstroConfig['trailingSlash']; + buildFormat: NonNullable['format']; +} + +interface RouterMatchRoute { + type: 'match'; + route: RouteData; + params: Params; + pathname: string; +} + +interface RouterMatchRedirect { + type: 'redirect'; + location: string; + status: ValidRedirectStatus; +} + +interface RouterMatchNone { + type: 'none'; + reason: 'no-match' | 'outside-base'; +} + +/** + * Result of routing a pathname. + * - match: route was found, includes route data and params. + * - redirect: canonical redirect (trailing slash or leading slash normalization). + * - none: no match (either outside base or no route pattern matched). + */ +export type RouterMatch = RouterMatchRoute | RouterMatchRedirect | RouterMatchNone; + +/** + * Matches request pathnames against a route list with base and trailing slash rules. + */ +export class Router { + #routes: RouteData[]; + #base: string; + #baseWithoutTrailingSlash: string; + #buildFormat: RouterOptions['buildFormat']; + #trailingSlash: RouterOptions['trailingSlash']; + + constructor(routes: RouteData[], options: RouterOptions) { + // Copy before sorting to avoid mutating the caller's route list. + // The Router owns route ordering to ensure consistent match priority. + this.#routes = [...routes].sort(routeComparator); + this.#base = normalizeBase(options.base); + this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#base); + this.#buildFormat = options.buildFormat; + this.#trailingSlash = options.trailingSlash; + } + + /** + * Match an input pathname against the route list. + * If allowWithoutBase is true, a non-base-prefixed path is still considered. + */ + public match( + inputPathname: string, + { allowWithoutBase = false }: { allowWithoutBase?: boolean } = {}, + ): RouterMatch { + const normalized = getRedirectForPathname(inputPathname); + if (normalized.redirect) { + return { type: 'redirect', location: normalized.redirect, status: 301 }; + } + + if (this.#base !== '/') { + const baseWithSlash = `${this.#baseWithoutTrailingSlash}/`; + if ( + this.#trailingSlash === 'always' && + (normalized.pathname === this.#baseWithoutTrailingSlash || + normalized.pathname === this.#base) + ) { + return { type: 'redirect', location: baseWithSlash, status: 301 }; + } + if (this.#trailingSlash === 'never' && normalized.pathname === baseWithSlash) { + return { type: 'redirect', location: this.#baseWithoutTrailingSlash, status: 301 }; + } + } + + const baseResult = stripBase( + normalized.pathname, + this.#base, + this.#baseWithoutTrailingSlash, + this.#trailingSlash, + ); + if (!baseResult) { + if (!allowWithoutBase) { + return { type: 'none', reason: 'outside-base' }; + } + } + + let pathname = baseResult ?? normalized.pathname; + if (this.#buildFormat === 'file') { + pathname = normalizeFileFormatPathname(pathname); + } + + const route = this.#routes.find((candidate) => { + if (candidate.pattern.test(pathname)) return true; + return candidate.fallbackRoutes.some((fallbackRoute) => fallbackRoute.pattern.test(pathname)); + }); + + if (!route) { + return { type: 'none', reason: 'no-match' }; + } + + const params = getParams(route, pathname); + return { type: 'match', route, params, pathname }; + } +} + +/** + * Normalize a base path to a leading-slash form. + */ +function normalizeBase(base: string): string { + if (!base) return '/'; + if (base === '/') return base; + return prependForwardSlash(base); +} + +/** + * Provide a redirect target for pathnames that need correction. + * - Ensures a leading slash. + * - Collapses multiple leading slashes into a single slash redirect. + */ +function getRedirectForPathname(pathname: string): { + pathname: string; + redirect?: string; +} { + let value = prependForwardSlash(pathname); + + if (value.startsWith('//')) { + const collapsed = `/${value.replace(/^\/+/, '')}`; + return { pathname: value, redirect: collapsed }; + } + + return { pathname: value }; +} + +/** + * Strip the configured base from the pathname and account for trailing slash policies. + * Returns null if the pathname is outside the base or should be redirected elsewhere. + */ +function stripBase( + pathname: string, + base: string, + baseWithoutTrailingSlash: string, + trailingSlash: RouterOptions['trailingSlash'], +): string | null { + if (base === '/') return pathname; + const baseWithSlash = `${baseWithoutTrailingSlash}/`; + + if (pathname === baseWithoutTrailingSlash || pathname === base) { + return trailingSlash === 'always' ? null : '/'; + } + if (pathname === baseWithSlash) { + return trailingSlash === 'never' ? null : '/'; + } + if (pathname.startsWith(baseWithSlash)) { + return pathname.slice(baseWithoutTrailingSlash.length); + } + + return null; +} + +/** + * Normalize file-format pathnames by removing .html and /index.html suffixes. + */ +function normalizeFileFormatPathname(pathname: string): string { + if (pathname.endsWith('/index.html')) { + const trimmed = pathname.slice(0, -'/index.html'.length); + return trimmed === '' ? '/' : trimmed; + } + + if (pathname.endsWith('.html')) { + const trimmed = pathname.slice(0, -'.html'.length); + return trimmed === '' ? '/' : trimmed; + } + + return pathname; +} diff --git a/packages/astro/src/core/routing/manifest/segment.ts b/packages/astro/src/core/routing/segment.ts similarity index 100% rename from packages/astro/src/core/routing/manifest/segment.ts rename to packages/astro/src/core/routing/segment.ts diff --git a/packages/astro/src/core/routing/validation.ts b/packages/astro/src/core/routing/validation.ts index 0e80211289cc..157ab1f8281d 100644 --- a/packages/astro/src/core/routing/validation.ts +++ b/packages/astro/src/core/routing/validation.ts @@ -3,21 +3,6 @@ import type { GetStaticPathsResult } from '../../types/public/common.js'; import type { RouteData } from '../../types/public/internal.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; -const VALID_PARAM_TYPES = ['string', 'undefined']; - -/** Throws error for invalid parameter in getStaticPaths() response */ -export function validateGetStaticPathsParameter([key, value]: [string, any], route: string) { - if (!VALID_PARAM_TYPES.includes(typeof value)) { - throw new AstroError({ - ...AstroErrorData.GetStaticPathsInvalidRouteParam, - message: AstroErrorData.GetStaticPathsInvalidRouteParam.message(key, value, typeof value), - location: { - file: route, - }, - }); - } -} - /** Error for deprecated or malformed route components */ export function validateDynamicRouteModule( mod: ComponentInstance, diff --git a/packages/astro/src/core/server-islands/endpoint.ts b/packages/astro/src/core/server-islands/endpoint.ts index 36ac9e7d5ed5..26508a92a559 100644 --- a/packages/astro/src/core/server-islands/endpoint.ts +++ b/packages/astro/src/core/server-islands/endpoint.ts @@ -9,7 +9,7 @@ import { createSlotValueFromString } from '../../runtime/server/render/slot.js'; import type { ComponentInstance, RoutesList } from '../../types/astro.js'; import type { RouteData, SSRManifest } from '../../types/public/internal.js'; import { decryptString } from '../encryption.js'; -import { getPattern } from '../routing/manifest/pattern.js'; +import { getPattern } from '../routing/pattern.js'; export const SERVER_ISLAND_ROUTE = '/_server-islands/[name]'; export const SERVER_ISLAND_COMPONENT = '_server-islands.astro'; diff --git a/packages/astro/src/core/sync/index.ts b/packages/astro/src/core/sync/index.ts index 8ca64d9ce8a7..261bdd7f8f96 100644 --- a/packages/astro/src/core/sync/index.ts +++ b/packages/astro/src/core/sync/index.ts @@ -30,7 +30,7 @@ import { isAstroError, } from '../errors/index.js'; import type { Logger } from '../logger/core.js'; -import { createRoutesList } from '../routing/manifest/create.js'; +import { createRoutesList } from '../routing/create-manifest.js'; import { ensureProcessNodeEnv } from '../util.js'; import { normalizePath } from '../viteUtils.js'; @@ -230,7 +230,7 @@ async function createTempViteServer( fsMod: fs, }, logger, - { dev: true, skipBuildOutputAssignment: true }, + { dev: true }, ); const tempViteServer = await createServer( diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index 294a51caef37..cf21926a280c 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -16,7 +16,7 @@ import { mergeConfig } from '../core/config/merge.js'; import { validateConfigRefined } from '../core/config/validate.js'; import { validateSetAdapter } from '../core/dev/adapter-validation.js'; import type { AstroIntegrationLogger, Logger } from '../core/logger/core.js'; -import { getRouteGenerator } from '../core/routing/manifest/generator.js'; +import { getRouteGenerator } from '../core/routing/generator.js'; import { getClientOutputDirectory } from '../prerender/utils.js'; import type { AstroSettings } from '../types/astro.js'; import type { AstroConfig } from '../types/public/config.js'; diff --git a/packages/astro/src/prerender/routing.ts b/packages/astro/src/prerender/routing.ts index a258c37bb5f5..d5a8393e0a4d 100644 --- a/packages/astro/src/prerender/routing.ts +++ b/packages/astro/src/prerender/routing.ts @@ -1,4 +1,4 @@ -import { routeIsRedirect } from '../core/routing/index.js'; +import { routeIsRedirect } from '../core/routing/helpers.js'; import { routeComparator } from '../core/routing/priority.js'; import type { RouteData, SSRManifest } from '../types/public/internal.js'; import type { RunnablePipeline } from '../vite-plugin-app/pipeline.js'; diff --git a/packages/astro/src/runtime/prerender/static-paths.ts b/packages/astro/src/runtime/prerender/static-paths.ts index 5d520dc4d914..ffe6b6924ec5 100644 --- a/packages/astro/src/runtime/prerender/static-paths.ts +++ b/packages/astro/src/runtime/prerender/static-paths.ts @@ -3,7 +3,7 @@ import type { Pipeline } from '../../core/base-pipeline.js'; import type { PathWithRoute } from '../../types/public/integrations.js'; import type { RouteData } from '../../types/public/internal.js'; import { stringifyParams } from '../../core/routing/params.js'; -import { routeIsRedirect, routeIsFallback, getFallbackRoute } from '../../core/routing/helpers.js'; +import { getFallbackRoute, routeIsFallback, routeIsRedirect } from '../../core/routing/helpers.js'; import { callGetStaticPaths } from '../../core/render/route-cache.js'; export type { PathWithRoute } from '../../types/public/integrations.js'; diff --git a/packages/astro/src/vite-plugin-app/pipeline.ts b/packages/astro/src/vite-plugin-app/pipeline.ts index 9a9251eea45e..3c4896902762 100644 --- a/packages/astro/src/vite-plugin-app/pipeline.ts +++ b/packages/astro/src/vite-plugin-app/pipeline.ts @@ -8,7 +8,7 @@ import type { ModuleLoader } from '../core/module-loader/index.js'; import { RedirectComponentInstance } from '../core/redirects/index.js'; import { loadRenderer } from '../core/render/index.js'; import { createDefaultRoutes } from '../core/routing/default.js'; -import { routeIsRedirect } from '../core/routing/index.js'; +import { routeIsRedirect } from '../core/routing/helpers.js'; import { findRouteToRewrite } from '../core/routing/rewrite.js'; import { isPage } from '../core/util.js'; import type { diff --git a/packages/astro/src/vite-plugin-pages/page.ts b/packages/astro/src/vite-plugin-pages/page.ts index c538d713f00f..0ae6505878b8 100644 --- a/packages/astro/src/vite-plugin-pages/page.ts +++ b/packages/astro/src/vite-plugin-pages/page.ts @@ -1,7 +1,7 @@ import type { Plugin as VitePlugin } from 'vite'; import { prependForwardSlash } from '../core/path.js'; import { DEFAULT_COMPONENTS } from '../core/routing/default.js'; -import { routeIsRedirect } from '../core/routing/index.js'; +import { routeIsRedirect } from '../core/routing/helpers.js'; import type { RoutesList } from '../types/astro.js'; import { VIRTUAL_PAGE_MODULE_ID, VIRTUAL_PAGE_RESOLVED_MODULE_ID } from './const.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js'; diff --git a/packages/astro/src/vite-plugin-pages/pages.ts b/packages/astro/src/vite-plugin-pages/pages.ts index a7ea68ae51db..48119cebe30a 100644 --- a/packages/astro/src/vite-plugin-pages/pages.ts +++ b/packages/astro/src/vite-plugin-pages/pages.ts @@ -1,6 +1,6 @@ import type { Plugin as VitePlugin } from 'vite'; import { DEFAULT_COMPONENTS } from '../core/routing/default.js'; -import { routeIsRedirect } from '../core/routing/index.js'; +import { routeIsRedirect } from '../core/routing/helpers.js'; import type { RoutesList } from '../types/astro.js'; import type { RouteData } from '../types/public/internal.js'; import { VIRTUAL_PAGE_MODULE_ID } from './const.js'; diff --git a/packages/astro/src/vite-plugin-routes/index.ts b/packages/astro/src/vite-plugin-routes/index.ts index 5161154905a6..2334b46d7358 100644 --- a/packages/astro/src/vite-plugin-routes/index.ts +++ b/packages/astro/src/vite-plugin-routes/index.ts @@ -7,8 +7,8 @@ import { serializeRouteData } from '../core/app/entrypoints/index.js'; import type { SerializedRouteInfo } from '../core/app/types.js'; import { warnMissingAdapter } from '../core/dev/adapter-validation.js'; import type { Logger } from '../core/logger/core.js'; -import { createRoutesList } from '../core/routing/manifest/create.js'; -import { getRoutePrerenderOption } from '../core/routing/manifest/prerender.js'; +import { createRoutesList } from '../core/routing/create-manifest.js'; +import { getRoutePrerenderOption } from '../core/routing/prerender.js'; import { isEndpoint, isPage } from '../core/util.js'; import { rootRelativePath } from '../core/viteUtils.js'; import type { AstroSettings, RoutesList } from '../types/astro.js'; diff --git a/packages/astro/test/astro-markdown-shiki.test.js b/packages/astro/test/astro-markdown-shiki.test.js index 42417897965c..c6fa6b21bef3 100644 --- a/packages/astro/test/astro-markdown-shiki.test.js +++ b/packages/astro/test/astro-markdown-shiki.test.js @@ -133,7 +133,7 @@ describe('Astro Markdown Shiki', () => { it('handles lazy loaded languages', () => { const lang = $('.astro-code').get(2); const segments = $('.line', lang).get(0).children; - assert.equal(segments.length, 7); + assert.ok(segments.length >= 6, 'Expected token segments for lazy-loaded language'); // With class-based styles, colors are in classes, not inline styles // Just verify all segments have class attributes for (let i = 0; i < segments.length; i++) { diff --git a/packages/astro/test/dev-routing.test.js b/packages/astro/test/dev-routing.test.js deleted file mode 100644 index 136b24188c17..000000000000 --- a/packages/astro/test/dev-routing.test.js +++ /dev/null @@ -1,414 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('Development Routing', () => { - describe('No site config', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; - - before(async () => { - fixture = await loadFixture({ root: './fixtures/without-site-config/' }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('200 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 200); - }); - - it('200 when loading non-UTF-8 file name', async () => { - const response = await fixture.fetch('/テスト'); - assert.equal(response.status, 200); - }); - - it('200 when loading include space file name', async () => { - const response = await fixture.fetch('/te st'); - assert.equal(response.status, 200); - }); - - it('200 when adding search params', async () => { - const response = await fixture.fetch('/?foo=bar'); - assert.equal(response.status, 200); - }); - - it('200 when loading non-root page', async () => { - const response = await fixture.fetch('/another'); - assert.equal(response.status, 200); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/1'); - assert.equal(response.status, 200); - }); - - it('redirects when loading double slash', async () => { - const response = await fixture.fetch('//', { redirect: 'manual' }); - assert.equal(response.status, 301); - assert.equal(response.headers.get('Location'), '/'); - }); - - it('redirects when loading multiple slashes', async () => { - const response = await fixture.fetch('/////', { redirect: 'manual' }); - assert.equal(response.status, 301); - assert.equal(response.headers.get('Location'), '/'); - }); - - it('does not redirect multiple internal slashes', async () => { - const response = await fixture.fetch('/another///here', { redirect: 'manual' }); - assert.equal(response.status, 404); - }); - - it('does not redirect slashes on query params', async () => { - const response = await fixture.fetch('/another?foo=bar///', { redirect: 'manual' }); - assert.equal(response.status, 200); - }); - - it('does redirect multiple trailing slashes with query params', async () => { - const response = await fixture.fetch('/another///?foo=bar///', { redirect: 'manual' }); - assert.equal(response.status, 301); - assert.equal(response.headers.get('Location'), '/another/?foo=bar///'); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/2'); - assert.equal(response.status, 404); - }); - }); - - describe('No subpath used', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - outDir: './dist-4007', - site: 'http://example.com/', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('200 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 200); - }); - - it('200 when loading non-root page', async () => { - const response = await fixture.fetch('/another'); - assert.equal(response.status, 200); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/1'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/2'); - assert.equal(response.status, 404); - }); - }); - - describe('Subpath with trailing slash', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - outDir: './dist-4008', - site: 'http://example.com', - base: '/blog', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath root', async () => { - const response = await fixture.fetch('/blog/'); - assert.equal(response.status, 200); - }); - - it('200 when loading subpath root without trailing slash', async () => { - const response = await fixture.fetch('/blog'); - assert.equal(response.status, 200); - }); - - it('200 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another/'); - assert.equal(response.status, 200); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1/'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2/'); - assert.equal(response.status, 404); - }); - }); - - describe('Subpath without trailing slash', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4009', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath root with trailing slash', async () => { - const response = await fixture.fetch('/blog/'); - assert.equal(response.status, 200); - }); - - it('200 when loading subpath root without trailing slash', async () => { - const response = await fixture.fetch('/blog'); - assert.equal(response.status, 200); - }); - - it('200 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another/'); - assert.equal(response.status, 200); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1/'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2/'); - assert.equal(response.status, 404); - }); - }); - - describe('Endpoint routes', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-endpoint-routes/', - site: 'http://example.com/', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('200 when loading /home.json', async () => { - const response = await fixture.fetch('/home.json'); - assert.equal(response.status, 200); - - const body = await response.text().then((text) => JSON.parse(text)); - assert.equal(body.title, 'home'); - }); - - it('200 when loading /thing1.json', async () => { - const response = await fixture.fetch('/thing1.json'); - assert.equal(response.status, 200); - - const body = await response.text().then((text) => JSON.parse(text)); - assert.equal(body.slug, 'thing1'); - assert.equal(body.title, '[slug]'); - }); - - it('200 when loading /thing2.json', async () => { - const response = await fixture.fetch('/thing2.json'); - assert.equal(response.status, 200); - - const body = await response.text().then((text) => JSON.parse(text)); - assert.equal(body.slug, 'thing2'); - assert.equal(body.title, '[slug]'); - }); - - it('200 when loading /data/thing3.json', async () => { - const response = await fixture.fetch('/data/thing3.json'); - assert.equal(response.status, 200); - - const body = await response.text().then((text) => JSON.parse(text)); - assert.equal(body.slug, 'thing3'); - assert.equal(body.title, 'data [slug]'); - }); - - it('200 when loading /data/thing4.json', async () => { - const response = await fixture.fetch('/data/thing4.json'); - assert.equal(response.status, 200); - - const body = await response.text().then((text) => JSON.parse(text)); - assert.equal(body.slug, 'thing4'); - assert.equal(body.title, 'data [slug]'); - }); - - it('error responses are served untouched', async () => { - const response = await fixture.fetch('/not-ok'); - assert.equal(response.status, 404); - assert.equal(response.headers.get('Content-Type'), 'text/plain;charset=UTF-8'); - const body = await response.text(); - assert.equal(body, 'Text from pages/not-ok.ts'); - }); - - it('correct MIME type when loading /home.json (static route)', async () => { - const response = await fixture.fetch('/home.json'); - assert.match(response.headers.get('content-type'), /application\/json/); - }); - - it('correct MIME type when loading /thing1.json (dynamic route)', async () => { - const response = await fixture.fetch('/thing1.json'); - assert.match(response.headers.get('content-type'), /application\/json/); - }); - - it('correct MIME type when loading /images/static.svg (static image)', async () => { - const response = await fixture.fetch('/images/static.svg'); - assert.match(response.headers.get('content-type'), /image\/svg\+xml/); - }); - - it('correct MIME type when loading /images/1.svg (dynamic image)', async () => { - const response = await fixture.fetch('/images/1.svg'); - assert.match(response.headers.get('content-type'), /image\/svg\+xml/); - }); - - it('correct encoding when loading /images/hex.ts', async () => { - const response = await fixture.fetch('/images/hex'); - const body = await response.arrayBuffer(); - const hex = Buffer.from(body).toString('hex', 0, 4); - - // Check if we have a PNG - assert.equal(hex, '89504e47'); - }); - }); - - describe('file format routing', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; - - before(async () => { - fixture = await loadFixture({ - build: { - format: 'file', - }, - root: './fixtures/without-site-config/', - site: 'http://example.com/', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('200 when loading /index.html', async () => { - const response = await fixture.fetch('/index.html'); - assert.equal(response.status, 200); - }); - - it('200 when loading /base-index.html', async () => { - const response = await fixture.fetch('/base-index.html'); - assert.equal(response.status, 200); - }); - - it('200 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 200); - }); - - it('200 when loading /テスト.html', async () => { - const response = await fixture.fetch('/テスト.html'); - assert.equal(response.status, 200); - }); - - it('200 when loading /テスト', async () => { - const response = await fixture.fetch('/テスト'); - assert.equal(response.status, 200); - }); - - it('200 when loading /te st.html', async () => { - const response = await fixture.fetch('/te st.html'); - assert.equal(response.status, 200); - }); - - it('200 when loading /te st', async () => { - const response = await fixture.fetch('/te st'); - assert.equal(response.status, 200); - }); - - it('200 when loading /another.html', async () => { - const response = await fixture.fetch('/another.html'); - assert.equal(response.status, 200); - }); - - it('200 when loading /another', async () => { - const response = await fixture.fetch('/another'); - assert.equal(response.status, 200); - }); - - it('200 when loading /1.html', async () => { - const response = await fixture.fetch('/1.html'); - assert.equal(response.status, 200); - }); - - it('200 when loading /1', async () => { - const response = await fixture.fetch('/1'); - assert.equal(response.status, 200); - }); - - it('200 when loading /html-ext/1', async () => { - const response = await fixture.fetch('/html-ext/1'); - assert.equal(response.status, 200); - assert.equal((await response.text()).includes('none: 1'), true); - }); - - it('200 when loading /html-ext/1.html.html', async () => { - const response = await fixture.fetch('/html-ext/1.html.html'); - assert.equal(response.status, 200); - assert.equal((await response.text()).includes('html: 1'), true); - }); - }); -}); diff --git a/packages/astro/test/dynamic-route-collision.test.js b/packages/astro/test/dynamic-route-collision.test.js deleted file mode 100644 index b6c15bfafb4e..000000000000 --- a/packages/astro/test/dynamic-route-collision.test.js +++ /dev/null @@ -1,71 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './test-utils.js'; - -describe('Dynamic route collision', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/dynamic-route-collision', - }); - - await fixture.build().catch(console.info); - }); - - it('Builds a static route when in conflict with a dynamic route', async () => { - const html = await fixture.readFile('/about/index.html'); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Static About'); - }); - - it('Builds a static nested index when in conflict with a dynamic route with slug with leading slash', async () => { - const html = await fixture.readFile('/test/index.html'); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Static Test'); - }); - - it('Builds a static route when in conflict with a spread route', async () => { - const html = await fixture.readFile('/who/index.html'); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Static Who We Are'); - }); - - it('Builds a static nested index when in conflict with a spread route', async () => { - const html = await fixture.readFile('/tags/index.html'); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Static Tags Index'); - }); - - it('Builds a static nested index when in conflict with a spread route with slug with leading slash', async () => { - const html = await fixture.readFile('/test/ing/index.html'); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Static TestIng'); - }); - - it('Builds a static root index when in conflict with a spread route', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Static Index'); - }); - - it('Builds a static index a nested when in conflict with a dynamic+spread route', async () => { - const html = await fixture.readFile('/en/index.html'); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Dynamic-only Localized Index'); - }); - - it('Builds a dynamic route when in conflict with a spread route', async () => { - const html = await fixture.readFile('/blog/index.html'); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Dynamic Blog'); - }); - - it('Builds the highest priority route out of two conflicting dynamic routes', async () => { - const html = await fixture.readFile('/order/index.html'); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Order from A'); - }); -}); diff --git a/packages/astro/test/fixtures/dynamic-route-collision/package.json b/packages/astro/test/fixtures/dynamic-route-collision/package.json deleted file mode 100644 index 35848efb79b6..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/dynamic-route-collision", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[...slug].astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[...slug].astro deleted file mode 100644 index 71cddfd173f3..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[...slug].astro +++ /dev/null @@ -1,39 +0,0 @@ ---- -export async function getStaticPaths() { - const pages = [ - { - slug: undefined, - title: 'Rest Index', - }, - { - slug: 'blog', - title: 'Rest Blog', - }, - { - slug: 'who', - title: 'Rest Who We Are', - }, - { - slug: '/test/ing/', - title: 'Rest TestIng', - }, - ]; - return pages.map(({ slug, title }) => { - return { - params: { slug }, - props: { title }, - }; - }); -} - -const { title } = Astro.props; ---- - - - - {title} - - -

{title}

- - diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[aOrder].astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[aOrder].astro deleted file mode 100644 index 8957a3dc8b25..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[aOrder].astro +++ /dev/null @@ -1,27 +0,0 @@ ---- -export async function getStaticPaths() { - const pages = [ - { - page: 'order', - title: 'Order from A', - }, - ]; - return pages.map(({ page, title }) => { - return { - params: { 'aOrder': page }, - props: { title }, - }; - }); -} - -const { title } = Astro.props; ---- - - - - {title} - - -

{title}

- - diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[bOrder].astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[bOrder].astro deleted file mode 100644 index 1b0eb30d8b9e..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[bOrder].astro +++ /dev/null @@ -1,27 +0,0 @@ ---- -export async function getStaticPaths() { - const pages = [ - { - page: 'order', - title: 'Order from B', - }, - ]; - return pages.map(({ page, title }) => { - return { - params: { 'bOrder': page }, - props: { title }, - }; - }); -} - -const { title } = Astro.props; ---- - - - - {title} - - -

{title}

- - diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[locale]/[...page].astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[locale]/[...page].astro deleted file mode 100644 index 1cdd7f766786..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[locale]/[...page].astro +++ /dev/null @@ -1,28 +0,0 @@ ---- -export async function getStaticPaths() { - const pages = [ - { - locale: 'en', - page: undefined, - title: 'Dynamic+Rest Localized Index', - }, - ]; - return pages.map(({ page, locale, title }) => { - return { - params: { page, locale }, - props: { title }, - }; - }); -} - -const { title } = Astro.props; ---- - - - - {title} - - -

{title}

- - diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[locale]/index.astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[locale]/index.astro deleted file mode 100644 index 425c55f740cd..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[locale]/index.astro +++ /dev/null @@ -1,27 +0,0 @@ ---- -export async function getStaticPaths() { - const pages = [ - { - locale: 'en', - title: 'Dynamic-only Localized Index', - }, - ]; - return pages.map(({ page, locale, title }) => { - return { - params: { page, locale }, - props: { title }, - }; - }); -} - -const { title } = Astro.props; ---- - - - - {title} - - -

{title}

- - diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[page].astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[page].astro deleted file mode 100644 index e9d13ab1c84c..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/[page].astro +++ /dev/null @@ -1,35 +0,0 @@ ---- -export async function getStaticPaths() { - const pages = [ - { - page: 'about', - title: 'Dynamic About', - }, - { - page: 'blog', - title: 'Dynamic Blog', - }, - { - page: '/test', - title: 'Dynamic Test', - }, - ]; - return pages.map(({ page, title }) => { - return { - params: { page }, - props: { title }, - }; - }); -} - -const { title } = Astro.props; ---- - - - - {title} - - -

{title}

- - diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/about.astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/about.astro deleted file mode 100644 index 16698abc16ae..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/about.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Static About Page - - -

Static About

- - diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/index.astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/index.astro deleted file mode 100644 index 043c4a5c963f..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/index.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Static Index Page - - -

Static Index

- - diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/tags/[...page].astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/tags/[...page].astro deleted file mode 100644 index fa7f87d3a683..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/tags/[...page].astro +++ /dev/null @@ -1,27 +0,0 @@ ---- -export async function getStaticPaths() { - const pages = [ - { - page: undefined, - title: 'Rest Tag Index', - }, - ]; - return pages.map(({ page, title }) => { - return { - params: { tag: page }, - props: { title }, - }; - }); -} - -const { title } = Astro.props; ---- - - - - {title} - - -

{title}

- - diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/tags/index.astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/tags/index.astro deleted file mode 100644 index 0f86c53130b5..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/tags/index.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Static Tags Index - - -

Static Tags Index

- - diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/test.astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/test.astro deleted file mode 100644 index c2913e78979f..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/test.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Static Test - - -

Static Test

- - diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/test/ing.astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/test/ing.astro deleted file mode 100644 index 0ff390b4647d..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/test/ing.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Static TestIng - - -

Static TestIng

- - diff --git a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/who.astro b/packages/astro/test/fixtures/dynamic-route-collision/src/pages/who.astro deleted file mode 100644 index 00d12c59deda..000000000000 --- a/packages/astro/test/fixtures/dynamic-route-collision/src/pages/who.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Static Who We Are - - -

Static Who We Are

- - diff --git a/packages/astro/test/fixtures/route-manifest/basic/about.astro b/packages/astro/test/fixtures/route-manifest/basic/about.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/basic/blog/[slug].astro b/packages/astro/test/fixtures/route-manifest/basic/blog/[slug].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/basic/blog/index.astro b/packages/astro/test/fixtures/route-manifest/basic/blog/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/basic/index.astro b/packages/astro/test/fixtures/route-manifest/basic/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/encoding/#.astro b/packages/astro/test/fixtures/route-manifest/encoding/#.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-dot/.unknown/foo.txt.js b/packages/astro/test/fixtures/route-manifest/hidden-dot/.unknown/foo.txt.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-dot/.well-known/dnt-policy.astro b/packages/astro/test/fixtures/route-manifest/hidden-dot/.well-known/dnt-policy.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-underscore/_foo.js b/packages/astro/test/fixtures/route-manifest/hidden-underscore/_foo.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-underscore/a/_b/c/d.js b/packages/astro/test/fixtures/route-manifest/hidden-underscore/a/_b/c/d.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-underscore/e/f/g/h.astro b/packages/astro/test/fixtures/route-manifest/hidden-underscore/e/f/g/h.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-underscore/i/_j.js b/packages/astro/test/fixtures/route-manifest/hidden-underscore/i/_j.js deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/hidden-underscore/index.astro b/packages/astro/test/fixtures/route-manifest/hidden-underscore/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/invalid-extension/about.astro b/packages/astro/test/fixtures/route-manifest/invalid-extension/about.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/invalid-extension/image.svg b/packages/astro/test/fixtures/route-manifest/invalid-extension/image.svg deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/invalid-extension/index.astro b/packages/astro/test/fixtures/route-manifest/invalid-extension/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/invalid-extension/styles.css b/packages/astro/test/fixtures/route-manifest/invalid-extension/styles.css deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/invalid-params/[foo][bar].astro b/packages/astro/test/fixtures/route-manifest/invalid-params/[foo][bar].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/invalid-rest/foo-[...rest]-bar.astro b/packages/astro/test/fixtures/route-manifest/invalid-rest/foo-[...rest]-bar.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro b/packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro_tmp b/packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro_tmp deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/multiple-slugs/[file].[ext].astro b/packages/astro/test/fixtures/route-manifest/multiple-slugs/[file].[ext].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/package.json b/packages/astro/test/fixtures/route-manifest/package.json deleted file mode 100644 index 86465c567222..000000000000 --- a/packages/astro/test/fixtures/route-manifest/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/route-manifest", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/abc.astro b/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/abc.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/[...deep_rest]/index.astro b/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/[...deep_rest]/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/[...deep_rest]/xyz.astro b/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/[...deep_rest]/xyz.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/index.astro b/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/index.astro b/packages/astro/test/fixtures/route-manifest/sorting/[...rest]/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/[wildcard].astro b/packages/astro/test/fixtures/route-manifest/sorting/[wildcard].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/_layout.astro b/packages/astro/test/fixtures/route-manifest/sorting/_layout.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/about.astro b/packages/astro/test/fixtures/route-manifest/sorting/about.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/index.astro b/packages/astro/test/fixtures/route-manifest/sorting/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/[id].astro b/packages/astro/test/fixtures/route-manifest/sorting/post/[id].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/_default.astro b/packages/astro/test/fixtures/route-manifest/sorting/post/_default.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/bar.astro b/packages/astro/test/fixtures/route-manifest/sorting/post/bar.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/f[xx].astro b/packages/astro/test/fixtures/route-manifest/sorting/post/f[xx].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/f[yy].astro b/packages/astro/test/fixtures/route-manifest/sorting/post/f[yy].astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/foo.astro b/packages/astro/test/fixtures/route-manifest/sorting/post/foo.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/route-manifest/sorting/post/index.astro b/packages/astro/test/fixtures/route-manifest/sorting/post/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/routing-priority/astro.config.mjs b/packages/astro/test/fixtures/routing-priority/astro.config.mjs deleted file mode 100644 index a5103a7f405f..000000000000 --- a/packages/astro/test/fixtures/routing-priority/astro.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'astro/config'; -import integration from './integration.mjs'; - -// https://astro.build/config -export default defineConfig({ - integrations: [integration()] -}); diff --git a/packages/astro/test/fixtures/routing-priority/integration.mjs b/packages/astro/test/fixtures/routing-priority/integration.mjs deleted file mode 100644 index 57ecc307336c..000000000000 --- a/packages/astro/test/fixtures/routing-priority/integration.mjs +++ /dev/null @@ -1,21 +0,0 @@ -export default function() { - return { - name: '@astrojs/test-integration', - hooks: { - 'astro:config:setup': ({ injectRoute }) => { - injectRoute({ - pattern: '/injected', - entrypoint: './src/to-inject.astro' - }); - injectRoute({ - pattern: '/_injected', - entrypoint: './src/_to-inject.astro' - }); - injectRoute({ - pattern: '/[id]', - entrypoint: './src/[id].astro' - }); - } - } - } -} diff --git a/packages/astro/test/fixtures/routing-priority/package.json b/packages/astro/test/fixtures/routing-priority/package.json deleted file mode 100644 index 01c23a914fa9..000000000000 --- a/packages/astro/test/fixtures/routing-priority/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/routing-priority", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/routing-priority/src/[id].astro b/packages/astro/test/fixtures/routing-priority/src/[id].astro deleted file mode 100644 index af235c9a851a..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/[id].astro +++ /dev/null @@ -1,21 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { params: { id: 'injected-1' } }, - { params: { id: 'injected-2' } } - ]; -} - -const { id } = Astro.params; ---- - - - - - Routing - - -

[id].astro

-

{id}

- - diff --git a/packages/astro/test/fixtures/routing-priority/src/_to-inject.astro b/packages/astro/test/fixtures/routing-priority/src/_to-inject.astro deleted file mode 100644 index 4c5b6428f48d..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/_to-inject.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- ---- - - - - - Routing - - -

to-inject.astro

- - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/[lang]/[...catchall].astro b/packages/astro/test/fixtures/routing-priority/src/pages/[lang]/[...catchall].astro deleted file mode 100644 index f212b6e53eaa..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/[lang]/[...catchall].astro +++ /dev/null @@ -1,23 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { params: { lang: 'de', catchall: '1/2' } }, - { params: { lang: 'en', catchall: '1/2' } } - ] - } ---- - - - - - - - Routing - - - -

[lang]/[...catchall].astro

-

{Astro.params.lang} | {Astro.params.catchall}

- - - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/[lang]/index.astro b/packages/astro/test/fixtures/routing-priority/src/pages/[lang]/index.astro deleted file mode 100644 index 4af288f0bfc4..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/[lang]/index.astro +++ /dev/null @@ -1,23 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { params: { lang: 'de' } }, // always shadowed by /de/index.astro - { params: { lang: 'en' } } - ]; - } ---- - - - - - - - Routing - - - -

[lang]/index.astro

-

{Astro.params.lang}

- - - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/[page].astro b/packages/astro/test/fixtures/routing-priority/src/pages/[page].astro deleted file mode 100644 index 74f5d463f2ad..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/[page].astro +++ /dev/null @@ -1,27 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { - params: { page: "page-1" } - }, - { - params: { page: "page-2" } - } - ] - } ---- - - - - - - - Routing - - - -

[page].astro

-

{Astro.params.page}

- - - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/[slug].astro b/packages/astro/test/fixtures/routing-priority/src/pages/[slug].astro deleted file mode 100644 index 55e8161bb4d2..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/[slug].astro +++ /dev/null @@ -1,27 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { - params: { slug: "slug-1" }, - }, - { - params: { slug: "slug-2" }, - } - ] - } ---- - - - - - - - Routing - - - -

[slug].astro

-

{Astro.params.slug}

- - - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[...slug].json.ts b/packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[...slug].json.ts deleted file mode 100644 index 4b26f41e56a0..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[...slug].json.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { APIRoute } from 'astro'; - -export const GET: APIRoute = async ({ params }) => { - return new Response( - JSON.stringify({ - path: params.slug, - }) - ); -}; - -export function getStaticPaths() { - return [{ params: { slug: 'a' } }, { params: { slug: 'b/c' } }]; -} diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[foo]-[bar].json.ts b/packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[foo]-[bar].json.ts deleted file mode 100644 index b9d2f0cabff1..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/api/catch/[foo]-[bar].json.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { APIRoute } from 'astro'; - -export const GET: APIRoute = async ({ params }) => { - return new Response( - JSON.stringify({ - foo: params.foo, - bar: params.bar, - }) - ); -}; - -export function getStaticPaths() { - return [{ params: { foo: 'a', bar: 'b' } }]; -} diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/de/index.astro b/packages/astro/test/fixtures/routing-priority/src/pages/de/index.astro deleted file mode 100644 index 9a82b84b988f..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/de/index.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -// This route should take priority over src/pages/[lang]/index.astro ---- - - - - - Routing - - -

de/index.astro (priority)

-

de

- - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/empty-paths/[...slug].astro b/packages/astro/test/fixtures/routing-priority/src/pages/empty-paths/[...slug].astro deleted file mode 100644 index 67130d51e8e7..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/empty-paths/[...slug].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [] -} -const { slug } = Astro.params ---- - - - - - - {slug} - - -

empty-paths/[...slug].astro

-

slug: {slug}

- - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/empty-slug/[...slug].astro b/packages/astro/test/fixtures/routing-priority/src/pages/empty-slug/[...slug].astro deleted file mode 100644 index da548ce4014c..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/empty-slug/[...slug].astro +++ /dev/null @@ -1,22 +0,0 @@ ---- -export function getStaticPaths() { - return [{ - params: { slug: undefined }, - }, { - params: { slug: 'potato' }, - }] -} -const { slug } = Astro.params ---- - - - - - - {slug} - - -

empty-slug/[...slug].astro

-

slug: {slug}

- - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/index.astro b/packages/astro/test/fixtures/routing-priority/src/pages/index.astro deleted file mode 100644 index b89f4ab033fd..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/index.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- ---- - - - - - Routing - - -

index.astro

- - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/posts/[...slug].astro b/packages/astro/test/fixtures/routing-priority/src/pages/posts/[...slug].astro deleted file mode 100644 index a3a57b006af4..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/posts/[...slug].astro +++ /dev/null @@ -1,24 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { - params: { slug: "1/2" }, - } - ] - } ---- - - - - - - - Routing - - - -

posts/[...slug].astro

-

{Astro.params.slug}

- - - diff --git a/packages/astro/test/fixtures/routing-priority/src/pages/posts/[pid].astro b/packages/astro/test/fixtures/routing-priority/src/pages/posts/[pid].astro deleted file mode 100644 index a9245b5ba1e8..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/pages/posts/[pid].astro +++ /dev/null @@ -1,23 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { params: { pid: 'post-1' } }, - { params: { pid: 'post-2' } } - ] - } ---- - - - - - - - Routing - - - -

posts/[pid].astro

-

{Astro.params.pid}

- - - diff --git a/packages/astro/test/fixtures/routing-priority/src/to-inject.astro b/packages/astro/test/fixtures/routing-priority/src/to-inject.astro deleted file mode 100644 index 4c5b6428f48d..000000000000 --- a/packages/astro/test/fixtures/routing-priority/src/to-inject.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- ---- - - - - - Routing - - -

to-inject.astro

- - diff --git a/packages/astro/test/fixtures/user-route-priority/package.json b/packages/astro/test/fixtures/user-route-priority/package.json deleted file mode 100644 index b175c228628c..000000000000 --- a/packages/astro/test/fixtures/user-route-priority/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/user-route-priority", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/user-route-priority/public/favicon.ico b/packages/astro/test/fixtures/user-route-priority/public/favicon.ico deleted file mode 100644 index 578ad458b8906c08fbed84f42b045fea04db89d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4286 zcmchZF=!M)6ox0}Fc8GdTHG!cdIY>nA!3n2f|wxIl0rn}Hl#=uf>?-!2r&jMEF^_k zh**lGut*gwBmoNv7AaB&2~nbzULg{WBhPQ{ZVzvF_HL8Cb&hv$_s#qN|IO^o>?+mA zuTW6tU%k~z<&{z+7$G%*nRsTcEO|90xy<-G5&JTt%CgZZCDT4%R?+{Vd^wh>P8_)} z`+dF$HQb9!>1o`Ivn;GInlCw{9T@Rt%q+d^T3Ke%cxkk;$v`{s^zCB9nHAv6w$Vbn z8fb<+eQTNM`;rf9#obfGnV#3+OQEUv4gU;{oA@zol%keY9-e>4W>p7AHmH~&!P7f7!Uj` zwgFeQ=<3G4O;mwWO`L!=R-=y3_~-DPjH3W^3f&jjCfC$o#|oGaahSL`_=f?$&Aa+W z2h8oZ+@?NUcjGW|aWJfbM*ZzxzmCPY`b~RobNrrj=rd`=)8-j`iSW64@0_b6?;GYk zNB+-fzOxlqZ?`y{OA$WigtZXa8)#p#=DPYxH=VeC_Q5q9Cv`mvW6*zU&Gnp1;oPM6 zaK_B3j(l^FyJgYeE9RrmDyhE7W2}}nW%ic#0v@i1E!yTey$W)U>fyd+!@2hWQ!Wa==NAtKoj`f3tp4y$Al`e;?)76?AjdaRR>|?&r)~3Git> zb1)a?uiv|R0_{m#A9c;7)eZ1y6l@yQ#oE*>(Z2fG-&&smPa2QTW>m*^K65^~`coP$ z8y5Y?iS<4Gz{Zg##$1mk)u-0;X|!xu^FCr;ce~X<&UWE&pBgqfYmEJTzpK9I%vr%b z3Ksd6qlPJLI%HFfeXK_^|BXiKZC>Ocu(Kk6hD3G-8usLzVG^q00Qh gz)s7ge@$ApxGu7=(6IGIk+uG&HTev01^#CH3$(Wk5&!@I diff --git a/packages/astro/test/fixtures/user-route-priority/src/pages/[number].astro b/packages/astro/test/fixtures/user-route-priority/src/pages/[number].astro deleted file mode 100644 index aa02e65c314e..000000000000 --- a/packages/astro/test/fixtures/user-route-priority/src/pages/[number].astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -const val = Number(Astro.params.number); ---- - - - Test app - - - -

{ val }

- - diff --git a/packages/astro/test/fixtures/virtual-routes/astro.config.mjs b/packages/astro/test/fixtures/virtual-routes/astro.config.mjs deleted file mode 100644 index 37a31e918d05..000000000000 --- a/packages/astro/test/fixtures/virtual-routes/astro.config.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import testAdapter from '../../test-adapter.js'; - -export default { - output: 'server', - adapter: testAdapter(), -}; diff --git a/packages/astro/test/fixtures/virtual-routes/package.json b/packages/astro/test/fixtures/virtual-routes/package.json deleted file mode 100644 index 1e11618c71e6..000000000000 --- a/packages/astro/test/fixtures/virtual-routes/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@test/virtual-routes", - "dependencies": { - "astro": "workspace:*" - } - } - \ No newline at end of file diff --git a/packages/astro/test/fixtures/virtual-routes/src/middleware.js b/packages/astro/test/fixtures/virtual-routes/src/middleware.js deleted file mode 100644 index 96b626601e7a..000000000000 --- a/packages/astro/test/fixtures/virtual-routes/src/middleware.js +++ /dev/null @@ -1,8 +0,0 @@ -export function onRequest (context, next) { - if (context.request.url.includes('/virtual')) { - return new Response('Virtual!!', { - status: 200, - }); - } - return next() -} diff --git a/packages/astro/test/preview-routing.test.js b/packages/astro/test/preview-routing.test.js deleted file mode 100644 index 05be4e8869e1..000000000000 --- a/packages/astro/test/preview-routing.test.js +++ /dev/null @@ -1,439 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './test-utils.js'; - -describe('Preview Routing', () => { - describe('build format: directory', () => { - describe('Subpath without trailing slash and trailingSlash: never', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4000', - build: { - format: 'directory', - }, - trailingSlash: 'never', - server: { - port: 4000, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath root with trailing slash', async () => { - const response = await fixture.fetch('/blog/'); - assert.equal(response.status, 200); - assert.equal(response.redirected, false); - }); - - it('200 when loading subpath root without trailing slash', async () => { - const response = await fixture.fetch('/blog'); - assert.equal(response.status, 200); - assert.equal(response.redirected, false); - }); - - it('404 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another/'); - assert.equal(response.status, 404); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2'); - assert.equal(response.status, 404); - }); - }); - - describe('Subpath without trailing slash and trailingSlash: always', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4001', - trailingSlash: 'always', - server: { - port: 4001, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath root with trailing slash', async () => { - const response = await fixture.fetch('/blog/'); - assert.equal(response.status, 200); - }); - - it('404 when loading subpath root without trailing slash', async () => { - const response = await fixture.fetch('/blog'); - assert.equal(response.status, 404); - }); - - it('200 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another/'); - assert.equal(response.status, 200); - }); - - it('404 when loading another page with subpath not used', async () => { - const response = await fixture.fetch('/blog/another'); - assert.equal(response.status, 404); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1/'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2/'); - assert.equal(response.status, 404); - }); - }); - - describe('Subpath without trailing slash and trailingSlash: ignore', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4002', - trailingSlash: 'ignore', - server: { - port: 4002, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath root with trailing slash', async () => { - const response = await fixture.fetch('/blog/'); - assert.equal(response.status, 200); - }); - - it('200 when loading subpath root without trailing slash', async () => { - const response = await fixture.fetch('/blog'); - assert.equal(response.status, 200); - }); - - it('200 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another/'); - assert.equal(response.status, 200); - }); - - it('200 when loading another page with subpath not used', async () => { - const response = await fixture.fetch('/blog/another'); - assert.equal(response.status, 200); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1/'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2/'); - assert.equal(response.status, 404); - }); - }); - - describe('Load custom 404.html', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - let $; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/custom-404-html/', - server: { - port: 4003, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('renders custom 404 for /a', async () => { - const res = await fixture.fetch('/a'); - assert.equal(res.status, 404); - - const html = await res.text(); - $ = cheerio.load(html); - - assert.equal($('h1').text(), 'Page not found'); - assert.equal($('p').text(), 'This 404 is a static HTML file.'); - }); - }); - }); - - describe('build format: file', () => { - describe('Subpath without trailing slash and trailingSlash: never', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4003', - build: { - format: 'file', - }, - trailingSlash: 'never', - server: { - port: 4004, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath root with trailing slash', async () => { - const response = await fixture.fetch('/blog/'); - assert.equal(response.status, 200); - assert.equal(response.redirected, false); - }); - - it('200 when loading subpath root without trailing slash', async () => { - const response = await fixture.fetch('/blog'); - assert.equal(response.status, 200); - assert.equal(response.redirected, false); - }); - - it('404 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another/'); - assert.equal(response.status, 404); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2'); - assert.equal(response.status, 404); - }); - }); - - describe('Subpath without trailing slash and trailingSlash: ignore', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4005', - build: { - format: 'file', - }, - trailingSlash: 'ignore', - server: { - port: 4006, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath root with trailing slash', async () => { - const response = await fixture.fetch('/blog/'); - assert.equal(response.status, 200); - }); - - it('200 when loading subpath root without trailing slash', async () => { - const response = await fixture.fetch('/blog'); - assert.equal(response.status, 200); - }); - - it('200 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another/'); - assert.equal(response.status, 200); - }); - - it('200 when loading another page with subpath not used', async () => { - const response = await fixture.fetch('/blog/another'); - assert.equal(response.status, 200); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1/'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2/'); - assert.equal(response.status, 404); - }); - }); - - describe('Exact file path', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-subpath-no-trailing-slash/', - base: '/blog', - outDir: './dist-4006', - build: { - format: 'file', - }, - trailingSlash: 'ignore', - server: { - port: 4007, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('404 when loading /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 404); - }); - - it('200 when loading subpath with index.html', async () => { - const response = await fixture.fetch('/blog/index.html'); - assert.equal(response.status, 200); - }); - - it('200 when loading another page with subpath used', async () => { - const response = await fixture.fetch('/blog/another.html'); - assert.equal(response.status, 200); - }); - - it('200 when loading dynamic route', async () => { - const response = await fixture.fetch('/blog/1.html'); - assert.equal(response.status, 200); - }); - - it('404 when loading invalid dynamic route', async () => { - const response = await fixture.fetch('/blog/2.html'); - assert.equal(response.status, 404); - }); - }); - - describe('Load custom 404.html', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').PreviewServer} */ - let previewServer; - - let $; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/custom-404-html/', - build: { - format: 'file', - }, - server: { - port: 4008, - }, - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('renders custom 404 for /a', async () => { - const res = await fixture.fetch('/a'); - assert.equal(res.status, 404); - - const html = await res.text(); - $ = cheerio.load(html); - - assert.equal($('h1').text(), 'Page not found'); - assert.equal($('p').text(), 'This 404 is a static HTML file.'); - }); - }); - }); -}); diff --git a/packages/astro/test/route-manifest.test.js b/packages/astro/test/route-manifest.test.js deleted file mode 100644 index 16940483389a..000000000000 --- a/packages/astro/test/route-manifest.test.js +++ /dev/null @@ -1,241 +0,0 @@ -// TODO: unskip this test -// import { expect } from 'chai'; -// import { fileURLToPath } from 'node:url'; -// import { createRouteManifest } from '../dist/core/routing/index.js'; -// import { validateConfig } from '../dist/core/config.js'; - -// const cwd = new URL('./fixtures/route-manifest/', import.meta.url); - -// const create = async (dir, trailingSlash) => { -// return createRouteManifest({ -// config: await validateConfig({ -// root: cwd.pathname, -// trailingSlash, -// }), -// cwd: fileURLToPath(cwd), -// }); -// }; -// function cleanRoutes(routes) { -// return routes.map((r) => { -// delete r.generate; -// return r; -// }); -// } - -// describe('route manifest', () => { -// it('creates routes with trailingSlashes = always', async () => { -// const { routes } = await create('basic', 'always'); -// expect(cleanRoutes(routes)).to.deep.equal([ -// { -// type: 'page', -// pattern: /^\/$/, -// params: [], -// component: 'basic/index.astro', -// pathname: '/', -// }, - -// { -// type: 'page', -// pattern: /^\/about\/$/, -// params: [], -// component: 'basic/about.astro', -// pathname: '/about', -// }, - -// { -// type: 'page', -// pattern: /^\/blog\/$/, -// params: [], -// component: 'basic/blog/index.astro', -// pathname: '/blog', -// }, - -// { -// type: 'page', -// pattern: /^\/blog\/([^/]+?)\/$/, -// params: ['slug'], -// component: 'basic/blog/[slug].astro', -// pathname: undefined, -// }, -// ]); -// }); - -// it('creates routes with trailingSlashes = never', async () => { -// const { routes } = await create('basic', 'never'); -// expect(cleanRoutes(routes)).to.deep.equal([ -// { -// type: 'page', -// pattern: /^\/$/, -// params: [], -// component: 'basic/index.astro', -// pathname: '/', -// }, - -// { -// type: 'page', -// pattern: /^\/about$/, -// params: [], -// component: 'basic/about.astro', -// pathname: '/about', -// }, - -// { -// type: 'page', -// pattern: /^\/blog$/, -// params: [], -// component: 'basic/blog/index.astro', -// pathname: '/blog', -// }, - -// { -// type: 'page', -// pattern: /^\/blog\/([^/]+?)$/, -// params: ['slug'], -// component: 'basic/blog/[slug].astro', -// pathname: undefined, -// }, -// ]); -// }); - -// it('creates routes with trailingSlashes = ignore', async () => { -// const { routes } = await create('basic', 'ignore'); -// expect(cleanRoutes(routes)).to.deep.equal([ -// { -// type: 'page', -// pattern: /^\/$/, -// params: [], -// component: 'basic/index.astro', -// pathname: '/', -// }, - -// { -// type: 'page', -// pattern: /^\/about\/?$/, -// params: [], -// component: 'basic/about.astro', -// pathname: '/about', -// }, - -// { -// type: 'page', -// pattern: /^\/blog\/?$/, -// params: [], -// component: 'basic/blog/index.astro', -// pathname: '/blog', -// }, - -// { -// type: 'page', -// pattern: /^\/blog\/([^/]+?)\/?$/, -// params: ['slug'], -// component: 'basic/blog/[slug].astro', -// pathname: undefined, -// }, -// ]); -// }); - -// it('encodes invalid characters', async () => { -// const { routes } = await create('encoding', 'always'); - -// // had to remove ? and " because windows - -// // const quote = 'encoding/".astro'; -// const hash = 'encoding/#.astro'; -// // const question_mark = 'encoding/?.astro'; - -// expect(routes.map((p) => p.pattern)).to.deep.equal([ -// // /^\/%22$/, -// /^\/%23\/$/, -// // /^\/%3F$/ -// ]); -// }); - -// it('ignores files and directories with leading underscores', async () => { -// const { routes } = await create('hidden-underscore', 'always'); - -// expect(routes.map((r) => r.component).filter(Boolean)).to.deep.equal(['hidden-underscore/index.astro', 'hidden-underscore/e/f/g/h.astro']); -// }); - -// it('ignores files and directories with leading dots except .well-known', async () => { -// const { routes } = await create('hidden-dot', 'always'); - -// expect(routes.map((r) => r.component).filter(Boolean)).to.deep.equal(['hidden-dot/.well-known/dnt-policy.astro']); -// }); - -// it('fails if dynamic params are not separated', async () => { -// expect(() => await create('invalid-params', 'always')).to.throw('Invalid route invalid-params/[foo][bar].astro — parameters must be separated'); -// }); - -// it('disallows rest parameters inside segments', async () => { -// expect(() => await create('invalid-rest', 'always')).to.throw('Invalid route invalid-rest/foo-[...rest]-bar.astro — rest parameter must be a standalone segment'); -// }); - -// it('ignores things that look like lockfiles', async () => { -// const { routes } = await create('lockfiles', 'always'); -// expect(cleanRoutes(routes)).to.deep.equal([ -// { -// type: 'page', -// pattern: /^\/foo\/$/, -// params: [], -// component: 'lockfiles/foo.astro', -// pathname: '/foo', -// }, -// ]); -// }); - -// it('ignores invalid route extensions', async () => { -// const { routes } = await create('invalid-extension', 'always'); -// expect(cleanRoutes(routes)).to.deep.equal([ -// { -// type: 'page', -// pattern: /^\/$/, -// params: [], -// component: 'invalid-extension/index.astro', -// pathname: '/', -// }, - -// { -// type: 'page', -// pattern: /^\/about\/$/, -// params: [], -// component: 'invalid-extension/about.astro', -// pathname: '/about', -// }, -// ]); -// }); - -// it('allows multiple slugs', async () => { -// const { routes } = await create('multiple-slugs', 'always'); - -// expect(cleanRoutes(routes)).to.deep.equal([ -// { -// type: 'page', -// pattern: /^\/([^/]+?)\.([^/]+?)\/$/, -// component: 'multiple-slugs/[file].[ext].astro', -// params: ['file', 'ext'], -// pathname: undefined, -// }, -// ]); -// }); - -// it('sorts routes correctly', async () => { -// const { routes } = await create('sorting', 'always'); - -// expect(routes.map((p) => p.component)).to.deep.equal([ -// 'sorting/index.astro', -// 'sorting/about.astro', -// 'sorting/post/index.astro', -// 'sorting/post/bar.astro', -// 'sorting/post/foo.astro', -// 'sorting/post/f[xx].astro', -// 'sorting/post/f[yy].astro', -// 'sorting/post/[id].astro', -// 'sorting/[wildcard].astro', -// 'sorting/[...rest]/deep/[...deep_rest]/xyz.astro', -// 'sorting/[...rest]/deep/[...deep_rest]/index.astro', -// 'sorting/[...rest]/deep/index.astro', -// 'sorting/[...rest]/abc.astro', -// 'sorting/[...rest]/index.astro', -// ]); -// }); -// }); diff --git a/packages/astro/test/routing-priority.test.js b/packages/astro/test/routing-priority.test.js deleted file mode 100644 index 888c28d1043a..000000000000 --- a/packages/astro/test/routing-priority.test.js +++ /dev/null @@ -1,270 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from './test-utils.js'; - -const routes = [ - { - description: 'matches / to index.astro', - url: '/', - h1: 'index.astro', - }, - { - description: 'matches /slug-1 to [slug].astro', - url: '/slug-1', - h1: '[slug].astro', - p: 'slug-1', - }, - { - description: 'matches /slug-2 to [slug].astro', - url: '/slug-2', - h1: '[slug].astro', - p: 'slug-2', - }, - { - description: 'matches /page-1 to [page].astro', - url: '/page-1', - h1: '[page].astro', - p: 'page-1', - }, - { - description: 'matches /page-2 to [page].astro', - url: '/page-2', - h1: '[page].astro', - p: 'page-2', - }, - { - description: 'matches /posts/post-1 to posts/[pid].astro', - url: '/posts/post-1', - h1: 'posts/[pid].astro', - p: 'post-1', - }, - { - description: 'matches /posts/post-2 to posts/[pid].astro', - url: '/posts/post-2', - h1: 'posts/[pid].astro', - p: 'post-2', - }, - { - description: 'matches /posts/1/2 to posts/[...slug].astro', - url: '/posts/1/2', - h1: 'posts/[...slug].astro', - p: '1/2', - }, - { - description: 'matches /de to de/index.astro', - url: '/de', - h1: 'de/index.astro (priority)', - }, - { - description: 'matches /en to [lang]/index.astro', - url: '/en', - h1: '[lang]/index.astro', - p: 'en', - }, - { - description: 'matches /de/1/2 to [lang]/[...catchall].astro', - url: '/de/1/2', - h1: '[lang]/[...catchall].astro', - p: 'de | 1/2', - }, - { - description: 'matches /en/1/2 to [lang]/[...catchall].astro', - url: '/en/1/2', - h1: '[lang]/[...catchall].astro', - p: 'en | 1/2', - }, - { - description: 'matches /injected to to-inject.astro', - url: '/injected', - h1: 'to-inject.astro', - }, - { - description: 'matches /_injected to to-inject.astro', - url: '/_injected', - h1: 'to-inject.astro', - }, - { - description: 'matches /injected-1 to [id].astro', - url: '/injected-1', - h1: '[id].astro', - p: 'injected-1', - }, - { - description: 'matches /injected-2 to [id].astro', - url: '/injected-2', - h1: '[id].astro', - p: 'injected-2', - }, - { - description: 'matches /empty-slug to empty-slug/[...slug].astro', - url: '/empty-slug', - h1: 'empty-slug/[...slug].astro', - p: 'slug: ', - }, - { - description: 'do not match /empty-slug/undefined to empty-slug/[...slug].astro', - url: '/empty-slug/undefined', - fourOhFour: true, - }, - { - description: 'do not match /empty-paths/hello to empty-paths/[...slug].astro', - url: '/empty-paths/hello', - fourOhFour: true, - }, - { - description: 'matches /api/catch/a.json to api/catch/[...slug].json.ts', - url: '/api/catch/a.json', - htmlMatch: JSON.stringify({ path: 'a' }), - }, - { - description: 'matches /api/catch/b/c.json to api/catch/[...slug].json.ts', - url: '/api/catch/b/c.json', - htmlMatch: JSON.stringify({ path: 'b/c' }), - }, - { - description: 'matches /api/catch/a-b.json to api/catch/[foo]-[bar].json.ts', - url: '/api/catch/a-b.json', - htmlMatch: JSON.stringify({ foo: 'a', bar: 'b' }), - }, -]; - -function appendForwardSlash(path) { - return path.endsWith('/') ? path : path + '/'; -} - -describe('Routing priority', () => { - describe('build', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/routing-priority/', - }); - await fixture.build(); - }); - - routes.forEach(({ description, url, fourOhFour, h1, p, htmlMatch }) => { - const isEndpoint = htmlMatch && !h1 && !p; - - it(description, async () => { - const htmlFile = isEndpoint ? url : `${appendForwardSlash(url)}index.html`; - - if (fourOhFour) { - assert.equal(fixture.pathExists(htmlFile), false); - return; - } - - const html = await fixture.readFile(htmlFile); - const $ = cheerioLoad(html); - - if (h1) { - assert.equal($('h1').text(), h1); - } - - if (p) { - assert.equal($('p').text(), p); - } - - if (htmlMatch) { - assert.equal(html, htmlMatch); - } - }); - }); - }); - - describe('dev', () => { - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/routing-priority/', - }); - - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - routes.forEach(({ description, url, fourOhFour, h1, p, htmlMatch }) => { - const isEndpoint = htmlMatch && !h1 && !p; - - // checks URLs as written above - it(description, async () => { - const html = await fixture.fetch(url).then((res) => res.text()); - const $ = cheerioLoad(html); - - if (fourOhFour) { - assert.equal($('title').text(), '404: Not Found'); - return; - } - - if (h1) { - assert.equal($('h1').text(), h1); - } - - if (p) { - assert.equal($('p').text(), p); - } - - if (htmlMatch) { - assert.equal(html, htmlMatch); - } - }); - - // skip for endpoint page test - if (isEndpoint) return; - - // checks with trailing slashes, ex: '/de/' instead of '/de' - it(`${description} (trailing slash)`, async () => { - const html = await fixture.fetch(appendForwardSlash(url)).then((res) => res.text()); - const $ = cheerioLoad(html); - - if (fourOhFour) { - assert.equal($('title').text(), '404: Not Found'); - return; - } - - if (h1) { - assert.equal($('h1').text(), h1); - } - - if (p) { - assert.equal($('p').text(), p); - } - - if (htmlMatch) { - assert.equal(html, htmlMatch); - } - }); - - // checks with index.html, ex: '/de/index.html' instead of '/de' - it(`${description} (index.html)`, async () => { - const html = await fixture - .fetch(`${appendForwardSlash(url)}index.html`) - .then((res) => res.text()); - const $ = cheerioLoad(html); - - if (fourOhFour) { - assert.equal($('title').text(), '404: Not Found'); - return; - } - - if (h1) { - assert.equal($('h1').text(), h1); - } - - if (p) { - assert.equal($('p').text(), p); - } - - if (htmlMatch) { - assert.equal(html, htmlMatch); - } - }); - }); - }); -}); diff --git a/packages/astro/test/units/routing/dev-routing.test.js b/packages/astro/test/units/routing/dev-routing.test.js new file mode 100644 index 000000000000..d2e6fc9f98e2 --- /dev/null +++ b/packages/astro/test/units/routing/dev-routing.test.js @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { Router } from '../../../dist/core/routing/router.js'; +import { dynamicPart, makeRoute, staticPart } from './test-helpers.js'; + +describe('dev routing (unit)', () => { + const trailingSlash = 'ignore'; + const routes = [ + makeRoute({ segments: [], trailingSlash, route: '/', pathname: '/', isIndex: true }), + makeRoute({ + segments: [[staticPart('テスト')]], + trailingSlash, + route: '/テスト', + pathname: '/テスト', + }), + makeRoute({ + segments: [[staticPart('te st')]], + trailingSlash, + route: '/te st', + pathname: '/te st', + }), + makeRoute({ + segments: [[staticPart('another')]], + trailingSlash, + route: '/another', + pathname: '/another', + }), + makeRoute({ + segments: [[dynamicPart('id')]], + trailingSlash, + route: '/[id]', + pathname: undefined, + }), + ]; + + const router = new Router(routes, { + base: '/', + trailingSlash, + buildFormat: 'directory', + }); + + it('matches root path', () => { + assert.equal(router.match('/').type, 'match'); + }); + + it('matches non-ASCII and space paths', () => { + assert.equal(router.match('/テスト').type, 'match'); + assert.equal(router.match('/te st').type, 'match'); + }); + + it('matches static and dynamic routes', () => { + const staticMatch = router.match('/another'); + assert.equal(staticMatch.type, 'match'); + assert.equal(staticMatch.route.route, '/another'); + + const dynamicMatch = router.match('/1'); + assert.equal(dynamicMatch.type, 'match'); + assert.deepEqual(dynamicMatch.params, { id: '1' }); + }); + + it('does not normalize internal multiple slashes', () => { + const match = router.match('/another///here'); + assert.equal(match.type, 'none'); + }); + + it('redirects multiple leading slashes', () => { + const match = router.match('/////'); + assert.equal(match.type, 'redirect'); + assert.equal(match.location, '/'); + }); +}); diff --git a/packages/astro/test/units/routing/dynamic-route-collision.test.js b/packages/astro/test/units/routing/dynamic-route-collision.test.js new file mode 100644 index 000000000000..b6a8d30ca875 --- /dev/null +++ b/packages/astro/test/units/routing/dynamic-route-collision.test.js @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { Router } from '../../../dist/core/routing/router.js'; +import { dynamicPart, makeRoute, spreadPart, staticPart } from './test-helpers.js'; + +describe('dynamic route collision (unit)', () => { + const trailingSlash = 'ignore'; + + it('prefers static routes over dynamic routes', () => { + const router = new Router( + [ + makeRoute({ + segments: [[dynamicPart('slug')]], + trailingSlash, + route: '/[slug]', + pathname: undefined, + }), + makeRoute({ + segments: [[staticPart('about')]], + trailingSlash, + route: '/about', + pathname: '/about', + }), + ], + { base: '/', trailingSlash, buildFormat: 'directory' }, + ); + + const match = router.match('/about'); + assert.equal(match.type, 'match'); + assert.equal(match.route.route, '/about'); + }); + + it('prefers static nested index over spread routes', () => { + const router = new Router( + [ + makeRoute({ + segments: [[spreadPart('...slug')]], + trailingSlash, + route: '/[...slug]', + pathname: undefined, + }), + makeRoute({ + segments: [[staticPart('test')]], + trailingSlash, + route: '/test', + pathname: '/test', + isIndex: true, + }), + ], + { base: '/', trailingSlash, buildFormat: 'directory' }, + ); + + const match = router.match('/test'); + assert.equal(match.type, 'match'); + assert.equal(match.route.route, '/test'); + }); + + it('prefers dynamic routes over spread routes for single segments', () => { + const router = new Router( + [ + makeRoute({ + segments: [[spreadPart('...slug')]], + trailingSlash, + route: '/[...slug]', + pathname: undefined, + }), + makeRoute({ + segments: [[dynamicPart('slug')]], + trailingSlash, + route: '/[slug]', + pathname: undefined, + }), + ], + { base: '/', trailingSlash, buildFormat: 'directory' }, + ); + + const match = router.match('/blog'); + assert.equal(match.type, 'match'); + assert.equal(match.route.route, '/[slug]'); + assert.deepEqual(match.params, { slug: 'blog' }); + }); +}); diff --git a/packages/astro/test/units/routing/generator.test.js b/packages/astro/test/units/routing/generator.test.js index d176c73ebc4c..3b7376e462c8 100644 --- a/packages/astro/test/units/routing/generator.test.js +++ b/packages/astro/test/units/routing/generator.test.js @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { getRouteGenerator } from '../../../dist/core/routing/manifest/generator.js'; +import { getRouteGenerator } from '../../../dist/core/routing/generator.js'; describe('routing - generator', () => { [ diff --git a/packages/astro/test/units/routing/manifest.test.js b/packages/astro/test/units/routing/manifest.test.js index 52ff563b99a6..de096e6f29e4 100644 --- a/packages/astro/test/units/routing/manifest.test.js +++ b/packages/astro/test/units/routing/manifest.test.js @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { Logger } from '../../../dist/core/logger/core.js'; -import { createRoutesList } from '../../../dist/core/routing/manifest/create.js'; +import { createRoutesList } from '../../../dist/core/routing/create-manifest.js'; import { createBasicSettings, createFixture } from '../test-utils.js'; function getManifestRoutes(manifest) { diff --git a/packages/astro/test/units/routing/parse-route.test.js b/packages/astro/test/units/routing/parse-route.test.js new file mode 100644 index 000000000000..c32aca1e6189 --- /dev/null +++ b/packages/astro/test/units/routing/parse-route.test.js @@ -0,0 +1,24 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseRoute } from '../../../dist/core/routing/parse-route.js'; + +describe('parseRoute', () => { + it('uses pageExtensions to strip file extensions', () => { + const options = { + config: { base: '/', trailingSlash: 'ignore' }, + pageExtensions: ['.mdx'], + }; + + const indexRoute = parseRoute('blog/index.mdx', options, { + component: 'src/pages/blog/index.mdx', + }); + assert.equal(indexRoute.route, '/blog'); + assert.equal(indexRoute.isIndex, true); + + const pageRoute = parseRoute('blog/post.mdx', options, { + component: 'src/pages/blog/post.mdx', + }); + assert.equal(pageRoute.route, '/blog/post'); + assert.equal(pageRoute.isIndex, false); + }); +}); diff --git a/packages/astro/test/units/routing/preview-routing.test.js b/packages/astro/test/units/routing/preview-routing.test.js new file mode 100644 index 000000000000..1f725514439f --- /dev/null +++ b/packages/astro/test/units/routing/preview-routing.test.js @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { Router } from '../../../dist/core/routing/router.js'; +import { dynamicPart, makeRoute, staticPart } from './test-helpers.js'; + +describe('preview routing (unit)', () => { + const routes = (trailingSlash) => [ + makeRoute({ segments: [], trailingSlash, route: '/', pathname: '/', isIndex: true }), + makeRoute({ + segments: [[staticPart('another')]], + trailingSlash, + route: '/another', + pathname: '/another', + }), + makeRoute({ + segments: [[dynamicPart('id')]], + trailingSlash, + route: '/[id]', + pathname: undefined, + }), + ]; + + it('matches base with trailingSlash=always', () => { + const router = new Router(routes('always'), { + base: '/blog', + trailingSlash: 'always', + buildFormat: 'directory', + }); + + assert.equal(router.match('/blog/').type, 'match'); + assert.equal(router.match('/blog').type, 'redirect'); + assert.equal(router.match('/blog/another/').type, 'match'); + assert.equal(router.match('/blog/another').type, 'none'); + }); + + it('matches base with trailingSlash=never', () => { + const router = new Router(routes('never'), { + base: '/blog', + trailingSlash: 'never', + buildFormat: 'directory', + }); + + assert.equal(router.match('/blog').type, 'match'); + assert.equal(router.match('/blog/').type, 'redirect'); + assert.equal(router.match('/blog/another').type, 'match'); + assert.equal(router.match('/blog/another/').type, 'none'); + }); + + it('matches base with trailingSlash=ignore', () => { + const router = new Router(routes('ignore'), { + base: '/blog', + trailingSlash: 'ignore', + buildFormat: 'directory', + }); + + assert.equal(router.match('/blog').type, 'match'); + assert.equal(router.match('/blog/').type, 'match'); + assert.equal(router.match('/blog/another').type, 'match'); + assert.equal(router.match('/blog/another/').type, 'match'); + }); + + it('accepts .html paths when buildFormat=file', () => { + const router = new Router(routes('ignore'), { + base: '/blog', + trailingSlash: 'ignore', + buildFormat: 'file', + }); + + assert.equal(router.match('/blog/index.html').type, 'match'); + assert.equal(router.match('/blog/another.html').type, 'match'); + const match = router.match('/blog/1.html'); + assert.equal(match.type, 'match'); + assert.deepEqual(match.params, { id: '1' }); + }); +}); diff --git a/packages/astro/test/units/routing/route-manifest.test.js b/packages/astro/test/units/routing/route-manifest.test.js new file mode 100644 index 000000000000..0727b5f7854d --- /dev/null +++ b/packages/astro/test/units/routing/route-manifest.test.js @@ -0,0 +1,284 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createRoutesFromEntries } from '../../../dist/core/routing/create-manifest.js'; + +const baseSettings = { + config: { + base: '/', + trailingSlash: 'always', + pageExtensions: [], + srcDir: new URL('file:///src/'), + root: new URL('file:///'), + redirects: {}, + }, + pageExtensions: [], + injectedRoutes: [], +}; + +const logger = { + warn() {}, +}; + +const stripPattern = (route) => ({ + ...route, + pattern: route.pattern.toString(), +}); + +describe('route manifest (entries)', () => { + it('creates routes with trailingSlash=always', () => { + const settings = { + ...baseSettings, + config: { ...baseSettings.config, trailingSlash: 'always' }, + }; + const routes = createRoutesFromEntries( + [ + { path: 'index.astro', isDir: false }, + { path: 'about.astro', isDir: false }, + { path: 'blog', isDir: true }, + { path: 'blog/index.astro', isDir: false }, + { path: 'blog/[slug].astro', isDir: false }, + ], + settings, + logger, + ); + + const output = routes.map(stripPattern); + assert.deepEqual( + output.map((r) => ({ + route: r.route, + pathname: r.pathname, + pattern: r.pattern, + })), + [ + { route: '/', pathname: '/', pattern: '/^\\/$/' }, + { route: '/about', pathname: '/about', pattern: '/^\\/about\\/$/' }, + { route: '/blog', pathname: '/blog', pattern: '/^\\/blog\\/$/' }, + { route: '/blog/[slug]', pathname: undefined, pattern: '/^\\/blog\\/([^/]+?)\\/$/' }, + ], + ); + }); + + it('creates routes with trailingSlash=never', () => { + const settings = { + ...baseSettings, + config: { ...baseSettings.config, trailingSlash: 'never' }, + }; + const routes = createRoutesFromEntries( + [ + { path: 'index.astro', isDir: false }, + { path: 'about.astro', isDir: false }, + { path: 'blog', isDir: true }, + { path: 'blog/index.astro', isDir: false }, + { path: 'blog/[slug].astro', isDir: false }, + ], + settings, + logger, + ); + + const output = routes.map(stripPattern); + assert.deepEqual( + output.map((r) => ({ + route: r.route, + pathname: r.pathname, + pattern: r.pattern, + })), + [ + { route: '/', pathname: '/', pattern: '/^\\/$/' }, + { route: '/about', pathname: '/about', pattern: '/^\\/about$/' }, + { route: '/blog', pathname: '/blog', pattern: '/^\\/blog$/' }, + { route: '/blog/[slug]', pathname: undefined, pattern: '/^\\/blog\\/([^/]+?)$/' }, + ], + ); + }); + + it('creates routes with trailingSlash=ignore', () => { + const settings = { + ...baseSettings, + config: { ...baseSettings.config, trailingSlash: 'ignore' }, + }; + const routes = createRoutesFromEntries( + [ + { path: 'index.astro', isDir: false }, + { path: 'about.astro', isDir: false }, + { path: 'blog', isDir: true }, + { path: 'blog/index.astro', isDir: false }, + { path: 'blog/[slug].astro', isDir: false }, + ], + settings, + logger, + ); + + const output = routes.map(stripPattern); + assert.deepEqual( + output.map((r) => ({ + route: r.route, + pathname: r.pathname, + pattern: r.pattern, + })), + [ + { route: '/', pathname: '/', pattern: '/^\\/$/' }, + { route: '/about', pathname: '/about', pattern: '/^\\/about\\/?$/' }, + { route: '/blog', pathname: '/blog', pattern: '/^\\/blog\\/?$/' }, + { route: '/blog/[slug]', pathname: undefined, pattern: '/^\\/blog\\/([^/]+?)\\/?$/' }, + ], + ); + }); + + it('ignores files and directories with leading underscores', () => { + const routes = createRoutesFromEntries( + [ + { path: '_hidden.astro', isDir: false }, + { path: '_dir', isDir: true }, + { path: '_dir/index.astro', isDir: false }, + { path: 'visible.astro', isDir: false }, + ], + baseSettings, + logger, + ); + + assert.deepEqual( + routes.map((route) => route.route), + ['/visible'], + ); + }); + + it('ignores dotfiles and dot directories except .well-known', () => { + const routes = createRoutesFromEntries( + [ + { path: '.hidden.astro', isDir: false }, + { path: '.git', isDir: true }, + { path: '.git/index.astro', isDir: false }, + { path: '.well-known', isDir: true }, + { path: '.well-known/dnt-policy.astro', isDir: false }, + ], + baseSettings, + logger, + ); + + assert.deepEqual( + routes.map((route) => route.route), + ['/.well-known/dnt-policy'], + ); + }); + + it('allows multiple slugs in a single segment', () => { + const routes = createRoutesFromEntries( + [{ path: '[file].[ext].astro', isDir: false }], + baseSettings, + logger, + ); + + assert.equal(routes.length, 1); + assert.equal(routes[0].route, '/[file].[ext]'); + }); + + it('throws when dynamic params are not separated', () => { + assert.throws( + () => + createRoutesFromEntries( + [{ path: '[foo][bar].astro', isDir: false }], + baseSettings, + logger, + 'src/pages', + ), + /parameters must be separated/, + ); + }); + + it('throws when rest params are inside segments', () => { + assert.throws( + () => + createRoutesFromEntries( + [{ path: 'foo-[...rest]-bar.astro', isDir: false }], + baseSettings, + logger, + 'src/pages', + ), + /rest parameter must be a standalone segment/, + ); + }); + + it('ignores non-page extensions but keeps valid ones', () => { + const routes = createRoutesFromEntries( + [ + { path: 'index.astro', isDir: false }, + { path: 'about.astro', isDir: false }, + { path: 'component.tsx', isDir: false }, + { path: 'note.md', isDir: false }, + { path: 'endpoint.ts', isDir: false }, + ], + baseSettings, + logger, + ); + + assert.deepEqual( + routes.map((route) => route.route), + ['/', '/about', '/note', '/endpoint'], + ); + const endpoint = routes.find((route) => route.route === '/endpoint'); + assert.equal(endpoint.type, 'endpoint'); + }); + + it('ignores lockfile-like entries with unsupported extensions', () => { + const routes = createRoutesFromEntries( + [ + { path: 'foo.astro', isDir: false }, + { path: 'package-lock.json', isDir: false }, + ], + baseSettings, + logger, + ); + + assert.deepEqual( + routes.map((route) => route.route), + ['/foo'], + ); + }); + + it('sorts routes correctly by priority', () => { + const routes = createRoutesFromEntries( + [ + { path: 'index.astro', isDir: false }, + { path: 'about.astro', isDir: false }, + { path: 'post', isDir: true }, + { path: 'post/index.astro', isDir: false }, + { path: 'post/bar.astro', isDir: false }, + { path: 'post/foo.astro', isDir: false }, + { path: 'post/f[xx].astro', isDir: false }, + { path: 'post/f[yy].astro', isDir: false }, + { path: 'post/[id].astro', isDir: false }, + { path: '[wildcard].astro', isDir: false }, + { path: '[...rest]', isDir: true }, + { path: '[...rest]/index.astro', isDir: false }, + { path: '[...rest]/abc.astro', isDir: false }, + { path: '[...rest]/deep', isDir: true }, + { path: '[...rest]/deep/index.astro', isDir: false }, + { path: '[...rest]/deep/[...deep_rest]', isDir: true }, + { path: '[...rest]/deep/[...deep_rest]/index.astro', isDir: false }, + { path: '[...rest]/deep/[...deep_rest]/xyz.astro', isDir: false }, + ], + baseSettings, + logger, + ); + + assert.deepEqual( + routes.map((route) => route.component), + [ + 'src/pages/index.astro', + 'src/pages/about.astro', + 'src/pages/post/index.astro', + 'src/pages/post/bar.astro', + 'src/pages/post/foo.astro', + 'src/pages/post/f[xx].astro', + 'src/pages/post/f[yy].astro', + 'src/pages/post/[id].astro', + 'src/pages/[wildcard].astro', + 'src/pages/[...rest]/index.astro', + 'src/pages/[...rest]/abc.astro', + 'src/pages/[...rest]/deep/index.astro', + 'src/pages/[...rest]/deep/[...deep_rest]/index.astro', + 'src/pages/[...rest]/deep/[...deep_rest]/xyz.astro', + ], + ); + }); +}); diff --git a/packages/astro/test/units/routing/route-matching.test.js b/packages/astro/test/units/routing/route-matching.test.js index cd2772c00aa8..3fb50fddc48f 100644 --- a/packages/astro/test/units/routing/route-matching.test.js +++ b/packages/astro/test/units/routing/route-matching.test.js @@ -3,8 +3,8 @@ import { after, before, describe, it } from 'node:test'; import * as cheerio from 'cheerio'; import { createContainer } from '../../../dist/core/dev/container.js'; import { createViteLoader } from '../../../dist/core/module-loader/vite.js'; -import { matchAllRoutes } from '../../../dist/core/routing/index.js'; -import { createRoutesList } from '../../../dist/core/routing/manifest/create.js'; +import { matchAllRoutes } from '../../../dist/core/routing/match.js'; +import { createRoutesList } from '../../../dist/core/routing/create-manifest.js'; import { getSortedPreloadedMatches } from '../../../dist/prerender/routing.js'; import { RunnablePipeline } from '../../../dist/vite-plugin-app/pipeline.js'; import { createDevelopmentManifest } from '../../../dist/vite-plugin-astro-server/plugin.js'; diff --git a/packages/astro/test/units/routing/router-match.test.js b/packages/astro/test/units/routing/router-match.test.js new file mode 100644 index 000000000000..106a81f4a8c4 --- /dev/null +++ b/packages/astro/test/units/routing/router-match.test.js @@ -0,0 +1,320 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { Router } from '../../../dist/core/routing/router.js'; +import { dynamicPart, makeRoute, spreadPart, staticPart } from './test-helpers.js'; + +describe('Router.match', () => { + it('prefers static routes over dynamic routes', () => { + const trailingSlash = 'ignore'; + const routes = [ + makeRoute({ + segments: [[dynamicPart('slug')]], + trailingSlash, + route: '/[slug]', + pathname: undefined, + }), + makeRoute({ + segments: [[staticPart('about')]], + trailingSlash, + route: '/about', + pathname: '/about', + }), + ]; + + const router = new Router(routes, { + base: '/', + trailingSlash, + buildFormat: 'directory', + }); + + const match = router.match('/about'); + assert.equal(match.type, 'match'); + assert.equal(match.route.route, '/about'); + assert.deepEqual(match.params, {}); + }); + + it('matches dynamic routes and returns params', () => { + const trailingSlash = 'ignore'; + const routes = [ + makeRoute({ + segments: [[dynamicPart('slug')]], + trailingSlash, + route: '/[slug]', + pathname: undefined, + }), + ]; + + const router = new Router(routes, { + base: '/', + trailingSlash, + buildFormat: 'directory', + }); + + const match = router.match('/hello'); + assert.equal(match.type, 'match'); + assert.equal(match.route.route, '/[slug]'); + assert.deepEqual(match.params, { slug: 'hello' }); + }); + + it('prefers single params over spread params', () => { + const trailingSlash = 'ignore'; + const routes = [ + makeRoute({ + segments: [[staticPart('posts')], [spreadPart('...slug')]], + trailingSlash, + route: '/posts/[...slug]', + pathname: undefined, + }), + makeRoute({ + segments: [[staticPart('posts')], [dynamicPart('id')]], + trailingSlash, + route: '/posts/[id]', + pathname: undefined, + }), + ]; + + const router = new Router(routes, { + base: '/', + trailingSlash, + buildFormat: 'directory', + }); + + const single = router.match('/posts/one'); + assert.equal(single.type, 'match'); + assert.equal(single.route.route, '/posts/[id]'); + assert.deepEqual(single.params, { id: 'one' }); + + const spread = router.match('/posts/one/two'); + assert.equal(spread.type, 'match'); + assert.equal(spread.route.route, '/posts/[...slug]'); + assert.deepEqual(spread.params, { slug: 'one/two' }); + }); + + it('prefers partially static dynamic segments over fully dynamic segments', () => { + const trailingSlash = 'ignore'; + const routes = [ + makeRoute({ + segments: [[dynamicPart('title')]], + trailingSlash, + route: '/[title]', + pathname: undefined, + }), + makeRoute({ + segments: [[staticPart('game-'), dynamicPart('title')]], + trailingSlash, + route: '/game-[title]', + pathname: undefined, + }), + ]; + + const router = new Router(routes, { + base: '/', + trailingSlash, + buildFormat: 'directory', + }); + + const match = router.match('/game-1'); + assert.equal(match.type, 'match'); + assert.equal(match.route.route, '/game-[title]'); + assert.deepEqual(match.params, { title: '1' }); + }); + + it('handles trailingSlash=always', () => { + const trailingSlash = 'always'; + const routes = [ + makeRoute({ + segments: [[staticPart('blog')]], + trailingSlash, + route: '/blog', + pathname: '/blog', + }), + ]; + + const router = new Router(routes, { + base: '/', + trailingSlash, + buildFormat: 'directory', + }); + + assert.equal(router.match('/blog').type, 'none'); + assert.equal(router.match('/blog/').type, 'match'); + }); + + it('handles trailingSlash=never', () => { + const trailingSlash = 'never'; + const routes = [ + makeRoute({ + segments: [[staticPart('blog')]], + trailingSlash, + route: '/blog', + pathname: '/blog', + }), + ]; + + const router = new Router(routes, { + base: '/', + trailingSlash, + buildFormat: 'directory', + }); + + assert.equal(router.match('/blog').type, 'match'); + assert.equal(router.match('/blog/').type, 'none'); + }); + + it('handles trailingSlash=ignore', () => { + const trailingSlash = 'ignore'; + const routes = [ + makeRoute({ + segments: [[staticPart('blog')]], + trailingSlash, + route: '/blog', + pathname: '/blog', + }), + ]; + + const router = new Router(routes, { + base: '/', + trailingSlash, + buildFormat: 'directory', + }); + + assert.equal(router.match('/blog').type, 'match'); + assert.equal(router.match('/blog/').type, 'match'); + }); + + it('matches within base path and rejects outside', () => { + const trailingSlash = 'ignore'; + const routes = [ + makeRoute({ + segments: [], + trailingSlash, + route: '/', + pathname: '/', + isIndex: true, + }), + makeRoute({ + segments: [[staticPart('about')]], + trailingSlash, + route: '/about', + pathname: '/about', + }), + ]; + + const router = new Router(routes, { + base: '/blog', + trailingSlash, + buildFormat: 'directory', + }); + + assert.equal(router.match('/blog').type, 'match'); + assert.equal(router.match('/blog/').type, 'match'); + assert.equal(router.match('/about').type, 'none'); + }); + + it('redirects base path when trailingSlash=always', () => { + const trailingSlash = 'always'; + const routes = [ + makeRoute({ + segments: [], + trailingSlash, + route: '/', + pathname: '/', + isIndex: true, + }), + ]; + + const router = new Router(routes, { + base: '/blog', + trailingSlash, + buildFormat: 'directory', + }); + + const match = router.match('/blog'); + assert.equal(match.type, 'redirect'); + if (match.type === 'redirect') { + assert.equal(match.location, '/blog/'); + assert.equal(match.status, 301); + } + }); + + it('accepts .html paths when buildFormat=file', () => { + const trailingSlash = 'ignore'; + const routes = [ + makeRoute({ + segments: [], + trailingSlash, + route: '/', + pathname: '/', + isIndex: true, + }), + makeRoute({ + segments: [[staticPart('about')]], + trailingSlash, + route: '/about', + pathname: '/about', + }), + ]; + + const router = new Router(routes, { + base: '/blog', + trailingSlash, + buildFormat: 'file', + }); + + const indexMatch = router.match('/blog/index.html'); + assert.equal(indexMatch.type, 'match'); + assert.equal(indexMatch.route.route, '/'); + + const aboutMatch = router.match('/blog/about.html'); + assert.equal(aboutMatch.type, 'match'); + assert.equal(aboutMatch.route.route, '/about'); + }); + + it('redirects multiple leading slashes at root', () => { + const trailingSlash = 'ignore'; + const routes = [ + makeRoute({ + segments: [], + trailingSlash, + route: '/', + pathname: '/', + isIndex: true, + }), + ]; + + const router = new Router(routes, { + base: '/', + trailingSlash, + buildFormat: 'directory', + }); + + const match = router.match('////'); + assert.equal(match.type, 'redirect'); + assert.equal(match.location, '/'); + }); + + it('redirects multiple leading slashes while preserving path', () => { + const trailingSlash = 'ignore'; + const routes = [ + makeRoute({ + segments: [[staticPart('foo')], [staticPart('bar')]], + trailingSlash, + route: '/foo/bar', + pathname: '/foo/bar', + }), + ]; + + const router = new Router(routes, { + base: '/', + trailingSlash, + buildFormat: 'directory', + }); + + const match = router.match('//foo/bar'); + assert.equal(match.type, 'redirect'); + if (match.type === 'redirect') { + assert.equal(match.location, '/foo/bar'); + assert.equal(match.status, 301); + } + }); +}); diff --git a/packages/astro/test/units/routing/routing-priority.test.js b/packages/astro/test/units/routing/routing-priority.test.js new file mode 100644 index 000000000000..bd542480a76d --- /dev/null +++ b/packages/astro/test/units/routing/routing-priority.test.js @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { Router } from '../../../dist/core/routing/router.js'; +import { dynamicPart, makeRoute, spreadPart, staticPart } from './test-helpers.js'; + +const trailingSlash = 'ignore'; + +const routes = [ + makeRoute({ + segments: [], + trailingSlash, + route: '/', + pathname: '/', + isIndex: true, + component: 'index.astro', + }), + makeRoute({ + segments: [[staticPart('de')]], + trailingSlash, + route: '/de', + pathname: '/de', + isIndex: true, + component: 'de/index.astro', + }), + makeRoute({ + segments: [[dynamicPart('lang')]], + trailingSlash, + route: '/[lang]', + pathname: undefined, + isIndex: true, + component: '[lang]/index.astro', + }), + makeRoute({ + segments: [[dynamicPart('lang')], [spreadPart('...catchall')]], + trailingSlash, + route: '/[lang]/[...catchall]', + pathname: undefined, + component: '[lang]/[...catchall].astro', + }), + makeRoute({ + segments: [[staticPart('posts')], [dynamicPart('pid')]], + trailingSlash, + route: '/posts/[pid]', + pathname: undefined, + component: 'posts/[pid].astro', + }), + makeRoute({ + segments: [[staticPart('posts')], [spreadPart('...slug')]], + trailingSlash, + route: '/posts/[...slug]', + pathname: undefined, + component: 'posts/[...slug].astro', + }), + makeRoute({ + segments: [[staticPart('injected')]], + trailingSlash, + route: '/injected', + pathname: '/injected', + component: 'to-inject.astro', + origin: 'external', + }), + makeRoute({ + segments: [[staticPart('_injected')]], + trailingSlash, + route: '/_injected', + pathname: '/_injected', + component: 'to-inject.astro', + origin: 'external', + }), + makeRoute({ + segments: [ + [staticPart('api')], + [staticPart('catch')], + [spreadPart('...slug'), staticPart('.json')], + ], + trailingSlash, + route: '/api/catch/[...slug].json', + pathname: undefined, + type: 'endpoint', + component: 'api/catch/[...slug].json.ts', + }), + makeRoute({ + segments: [ + [staticPart('api')], + [staticPart('catch')], + [dynamicPart('foo'), staticPart('-'), dynamicPart('bar'), staticPart('.json')], + ], + trailingSlash, + route: '/api/catch/[foo]-[bar].json', + pathname: undefined, + type: 'endpoint', + component: 'api/catch/[foo]-[bar].json.ts', + }), + makeRoute({ + segments: [[staticPart('empty-slug')], [spreadPart('...slug')]], + trailingSlash, + route: '/empty-slug/[...slug]', + pathname: undefined, + component: 'empty-slug/[...slug].astro', + }), +]; + +describe('routing priority (unit)', () => { + const router = new Router(routes, { + base: '/', + trailingSlash, + buildFormat: 'directory', + }); + + it('matches / to index route', () => { + const match = router.match('/'); + assert.equal(match.type, 'match'); + assert.equal(match.route.component, 'index.astro'); + }); + + it('matches static locale route before dynamic', () => { + const match = router.match('/de'); + assert.equal(match.type, 'match'); + assert.equal(match.route.component, 'de/index.astro'); + }); + + it('matches dynamic locale route when static does not exist', () => { + const match = router.match('/en'); + assert.equal(match.type, 'match'); + assert.equal(match.route.component, '[lang]/index.astro'); + assert.deepEqual(match.params, { lang: 'en' }); + }); + + it('matches dynamic catchall under locale', () => { + const match = router.match('/de/1/2'); + assert.equal(match.type, 'match'); + assert.equal(match.route.component, '[lang]/[...catchall].astro'); + assert.deepEqual(match.params, { lang: 'de', catchall: '1/2' }); + }); + + it('matches posts/[pid] over posts/[...slug]', () => { + const match = router.match('/posts/post-1'); + assert.equal(match.type, 'match'); + assert.equal(match.route.component, 'posts/[pid].astro'); + assert.deepEqual(match.params, { pid: 'post-1' }); + }); + + it('matches posts/[...slug] for deeper paths', () => { + const match = router.match('/posts/1/2'); + assert.equal(match.type, 'match'); + assert.equal(match.route.component, 'posts/[...slug].astro'); + assert.deepEqual(match.params, { slug: '1/2' }); + }); + + it('matches injected static routes', () => { + const injected = router.match('/injected'); + assert.equal(injected.type, 'match'); + assert.equal(injected.route.component, 'to-inject.astro'); + + const underscored = router.match('/_injected'); + assert.equal(underscored.type, 'match'); + assert.equal(underscored.route.component, 'to-inject.astro'); + }); + + it('matches endpoint catch-all JSON route', () => { + const match = router.match('/api/catch/a.json'); + assert.equal(match.type, 'match'); + assert.equal(match.route.component, 'api/catch/[...slug].json.ts'); + assert.deepEqual(match.params, { slug: 'a' }); + }); + + it('matches endpoint catch-all JSON route for nested paths', () => { + const match = router.match('/api/catch/b/c.json'); + assert.equal(match.type, 'match'); + assert.equal(match.route.component, 'api/catch/[...slug].json.ts'); + assert.deepEqual(match.params, { slug: 'b/c' }); + }); + + it('matches endpoint [foo]-[bar].json over catch-all', () => { + const match = router.match('/api/catch/a-b.json'); + assert.equal(match.type, 'match'); + assert.equal(match.route.component, 'api/catch/[foo]-[bar].json.ts'); + assert.deepEqual(match.params, { foo: 'a', bar: 'b' }); + }); + + it('matches empty spread params as undefined', () => { + const match = router.match('/empty-slug'); + assert.equal(match.type, 'match'); + assert.equal(match.route.component, 'empty-slug/[...slug].astro'); + assert.deepEqual(match.params, { slug: undefined }); + }); +}); diff --git a/packages/astro/test/units/routing/test-helpers.js b/packages/astro/test/units/routing/test-helpers.js new file mode 100644 index 000000000000..983a987b7963 --- /dev/null +++ b/packages/astro/test/units/routing/test-helpers.js @@ -0,0 +1,58 @@ +import { getPattern } from '../../../dist/core/routing/pattern.js'; + +/** @param {string} content */ +const staticPart = (content) => ({ content, dynamic: false, spread: false }); +/** @param {string} content */ +const dynamicPart = (content) => ({ content, dynamic: true, spread: false }); +/** @param {string} content */ +const spreadPart = (content) => ({ content, dynamic: true, spread: true }); + +/** + * @param {object} options + * @param {import('../../../dist/types/public/internal.js').RoutePart[][]} options.segments + * @param {'always' | 'never' | 'ignore'} options.trailingSlash + * @param {string} options.route + * @param {string | undefined} options.pathname + * @param {'page' | 'endpoint' | 'redirect' | 'fallback'} [options.type] + * @param {string | undefined} [options.component] + * @param {boolean} [options.isIndex] + * @param {boolean} [options.prerender] + * @param {'project' | 'internal'} [options.origin] + * @param {string[] | undefined} [options.params] + */ +const makeRoute = ({ + segments, + trailingSlash, + route, + pathname, + type = 'page', + component, + isIndex = false, + prerender = false, + origin = 'project', + params, +}) => { + const routeParams = + params ?? + segments + .flat() + .filter((part) => part.dynamic) + .map((part) => part.content); + + return { + route, + component: component ?? route, + params: routeParams, + pathname, + pattern: getPattern(segments, '/', trailingSlash), + segments, + type, + prerender, + fallbackRoutes: [], + distURL: [], + isIndex, + origin, + }; +}; + +export { dynamicPart, makeRoute, spreadPart, staticPart }; diff --git a/packages/astro/test/units/routing/virtual-routes.test.js b/packages/astro/test/units/routing/virtual-routes.test.js new file mode 100644 index 000000000000..6a15d622f1b2 --- /dev/null +++ b/packages/astro/test/units/routing/virtual-routes.test.js @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { Router } from '../../../dist/core/routing/router.js'; +import { makeRoute, staticPart } from './test-helpers.js'; + +describe('virtual routes (unit)', () => { + const trailingSlash = 'ignore'; + const routes = [ + makeRoute({ + segments: [[staticPart('virtual')]], + trailingSlash, + route: '/virtual', + pathname: '/virtual', + origin: 'external', + }), + ]; + + const router = new Router(routes, { + base: '/', + trailingSlash, + buildFormat: 'directory', + }); + + it('matches injected virtual routes', () => { + const match = router.match('/virtual'); + assert.equal(match.type, 'match'); + assert.equal(match.route.route, '/virtual'); + assert.equal(match.route.origin, 'external'); + }); +}); diff --git a/packages/astro/test/user-route-priority.test.js b/packages/astro/test/user-route-priority.test.js deleted file mode 100644 index dfe62500de11..000000000000 --- a/packages/astro/test/user-route-priority.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import testAdapter from './test-adapter.js'; -import { loadFixture } from './test-utils.js'; - -describe('User routes have priority over internal routes', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - const root = './fixtures/user-route-priority/'; - fixture = await loadFixture({ - root, - output: 'server', - adapter: testAdapter(), - }); - await fixture.build(); - }); - - async function fetchHTML(path) { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com' + path); - const response = await app.render(request); - const html = await response.text(); - return html; - } - - it("Page doesn't error with non-404 as number", async () => { - const html = await fetchHTML('/123'); - const $ = cheerioLoad(html); - assert.equal($('h1').text(), '123'); - }); - - it("Page doesn't error with 404 as number", async () => { - const html = await fetchHTML('/404'); - const $ = cheerioLoad(html); - assert.equal($('h1').text(), '404'); - }); -}); diff --git a/packages/astro/test/virtual-routes.test.js b/packages/astro/test/virtual-routes.test.js deleted file mode 100644 index e0a64aefe682..000000000000 --- a/packages/astro/test/virtual-routes.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('virtual routes - dev', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/virtual-routes/', - }); - await fixture.build(); - }); - - it('should render a virtual route - dev', async () => { - const devServer = await fixture.startDevServer(); - const response = await fixture.fetch('/virtual'); - const html = await response.text(); - assert.equal(html.includes('Virtual!!'), true); - await devServer.stop(); - }); - - it('should render a virtual route - app', async () => { - const app = await fixture.loadTestAdapterApp(); - const response = await app.render(new Request('https://example.com/virtual')); - const html = await response.text(); - assert.equal(html.includes('Virtual!!'), true); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e60dab36b9a..a574ab588de5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3266,12 +3266,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/dynamic-route-collision: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/entry-file-names: dependencies: '@astrojs/preact': @@ -4032,18 +4026,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/route-manifest: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/routing-priority: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/scoped-style-strategy: dependencies: astro: @@ -4517,12 +4499,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/user-route-priority: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/view-transitions: dependencies: '@astrojs/react': @@ -4544,12 +4520,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/virtual-routes: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/vitest: dependencies: astro: From 9d293c21afc17fccaa55ea6c69f6403ad288ba57 Mon Sep 17 00:00:00 2001 From: Julian Wolf Date: Tue, 24 Feb 2026 16:31:37 +0100 Subject: [PATCH 02/10] fix: include slot scripts in server island response (#15633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: include slot scripts in server island response Components passed as slots to server:defer components had their scripts silently dropped because renderSlotToString separates HTML content from render instructions, and only the HTML was serialized into the island payload via content.toString(). This appends script instructions to the slot HTML before encryption, so the island response includes the scripts needed for interactivity. This may result in duplicate script injection when the same component is also used statically on the page. Deduplication is not feasible here due to the concurrent BufferedRenderer model — renderedScripts is not yet populated at the time island payloads are constructed. * add changeset * fix: cast SlotString for type safety * use Array.isArray guard for defensive type safety * test: verify slotted component scripts in server island response * Update .changeset/server-island-slot-scripts.md Co-authored-by: Florian Lefebvre * improved type import --------- Co-authored-by: Florian Lefebvre --- .changeset/server-island-slot-scripts.md | 5 +++++ .../src/runtime/server/render/server-islands.ts | 17 +++++++++++++++-- .../ssr/src/components/ScriptedCounter.astro | 17 +++++++++++++++++ .../ssr/src/components/Wrapper.astro | 5 +++++ .../ssr/src/pages/slot-with-script.astro | 15 +++++++++++++++ packages/astro/test/server-islands.test.js | 14 ++++++++++++++ 6 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 .changeset/server-island-slot-scripts.md create mode 100644 packages/astro/test/fixtures/server-islands/ssr/src/components/ScriptedCounter.astro create mode 100644 packages/astro/test/fixtures/server-islands/ssr/src/components/Wrapper.astro create mode 100644 packages/astro/test/fixtures/server-islands/ssr/src/pages/slot-with-script.astro diff --git a/.changeset/server-island-slot-scripts.md b/.changeset/server-island-slot-scripts.md new file mode 100644 index 000000000000..3066706588e2 --- /dev/null +++ b/.changeset/server-island-slot-scripts.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a case where ` diff --git a/packages/astro/test/fixtures/server-islands/ssr/src/components/Wrapper.astro b/packages/astro/test/fixtures/server-islands/ssr/src/components/Wrapper.astro new file mode 100644 index 000000000000..f4c3cfc62527 --- /dev/null +++ b/packages/astro/test/fixtures/server-islands/ssr/src/components/Wrapper.astro @@ -0,0 +1,5 @@ +--- +--- +
+ +
diff --git a/packages/astro/test/fixtures/server-islands/ssr/src/pages/slot-with-script.astro b/packages/astro/test/fixtures/server-islands/ssr/src/pages/slot-with-script.astro new file mode 100644 index 000000000000..2db634722b90 --- /dev/null +++ b/packages/astro/test/fixtures/server-islands/ssr/src/pages/slot-with-script.astro @@ -0,0 +1,15 @@ +--- +import Wrapper from '../components/Wrapper.astro'; +import ScriptedCounter from '../components/ScriptedCounter.astro'; +--- + + + Slot with script + + + + +
Loading...
+
+ + diff --git a/packages/astro/test/server-islands.test.js b/packages/astro/test/server-islands.test.js index 330441add0c1..dfcaf8931e53 100644 --- a/packages/astro/test/server-islands.test.js +++ b/packages/astro/test/server-islands.test.js @@ -245,6 +245,20 @@ describe('Server islands', () => { assert.equal(fetchMatch.length, 2, 'should include props in the query string'); assert.equal(fetchMatch[1], '', 'should not include encrypted empty props'); }); + + it('includes script from slotted component in island response', async () => { + const res = await fixture.fetch('/slot-with-script'); + assert.equal(res.status, 200); + const html = await res.text(); + // Extract the island fetch URL from the page + const urlMatch = html.match(/fetch\('(\/_server-islands\/Wrapper\?[^']+)'/); + assert.ok(urlMatch, 'should have a server island fetch URL'); + const islandRes = await fixture.fetch(urlMatch[1]); + assert.equal(islandRes.status, 200); + const islandHtml = await islandRes.text(); + assert.ok(islandHtml.includes(' { From 9e47a0c6d3a30c6a827a4947e666ea49a389def3 Mon Sep 17 00:00:00 2001 From: Julian Wolf Date: Tue, 24 Feb 2026 15:32:52 +0000 Subject: [PATCH 03/10] [ci] format --- packages/astro/test/server-islands.test.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/astro/test/server-islands.test.js b/packages/astro/test/server-islands.test.js index dfcaf8931e53..5d0016867703 100644 --- a/packages/astro/test/server-islands.test.js +++ b/packages/astro/test/server-islands.test.js @@ -256,8 +256,14 @@ describe('Server islands', () => { const islandRes = await fixture.fetch(urlMatch[1]); assert.equal(islandRes.status, 200); const islandHtml = await islandRes.text(); - assert.ok(islandHtml.includes(' Date: Tue, 24 Feb 2026 16:35:22 +0100 Subject: [PATCH 04/10] fix: delete original images from prerendered pages in SSR builds (#15549) Co-authored-by: Claude Opus 4.6 Co-authored-by: Princesseuh <3019731+Princesseuh@users.noreply.github.com> --- .../fix-ssr-prerendered-image-deletion.md | 5 +++ packages/astro/src/assets/build/generate.ts | 5 ++- .../astro/src/assets/vite-plugin-assets.ts | 14 +++++--- .../core-image-deletion-ssr/astro.config.mjs | 3 ++ .../core-image-deletion-ssr/package.json | 8 +++++ .../src/assets/onlyone.jpg | Bin 0 -> 11621 bytes .../src/assets/shared.jpg | Bin 0 -> 46343 bytes .../src/assets/twoofus.jpg | Bin 0 -> 11677 bytes .../src/pages/index.astro | 11 +++++++ .../src/pages/prerendered.astro | 18 ++++++++++ packages/astro/test/image-deletion.test.js | 31 ++++++++++++++++++ pnpm-lock.yaml | 6 ++++ 12 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-ssr-prerendered-image-deletion.md create mode 100644 packages/astro/test/fixtures/core-image-deletion-ssr/astro.config.mjs create mode 100644 packages/astro/test/fixtures/core-image-deletion-ssr/package.json create mode 100644 packages/astro/test/fixtures/core-image-deletion-ssr/src/assets/onlyone.jpg create mode 100644 packages/astro/test/fixtures/core-image-deletion-ssr/src/assets/shared.jpg create mode 100644 packages/astro/test/fixtures/core-image-deletion-ssr/src/assets/twoofus.jpg create mode 100644 packages/astro/test/fixtures/core-image-deletion-ssr/src/pages/index.astro create mode 100644 packages/astro/test/fixtures/core-image-deletion-ssr/src/pages/prerendered.astro diff --git a/.changeset/fix-ssr-prerendered-image-deletion.md b/.changeset/fix-ssr-prerendered-image-deletion.md new file mode 100644 index 000000000000..01041c39b228 --- /dev/null +++ b/.changeset/fix-ssr-prerendered-image-deletion.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fixes an issue where original (unoptimized) images from prerendered pages could be kept in the build output during SSR builds. diff --git a/packages/astro/src/assets/build/generate.ts b/packages/astro/src/assets/build/generate.ts index c275aa50ec4a..eacbe4ff0c79 100644 --- a/packages/astro/src/assets/build/generate.ts +++ b/packages/astro/src/assets/build/generate.ts @@ -108,10 +108,9 @@ export async function generateImagesForPath( await generateImage(transform.finalPath, transform.transform); } - // In SSR, we cannot know if an image is referenced in a server-rendered page, so we can't delete anything - // For instance, the same image could be referenced in both a server-rendered page and build-time-rendered page + // Delete original images that are only used for optimization + // The referencedImages set tracks images that were used via raw `src` access (e.g., ). if ( - !env.isSSR && transformsAndPath.originalSrcPath && !globalThis.astroAsset.referencedImages?.has(transformsAndPath.originalSrcPath) ) { diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 14d329900d0a..f0697c357d3b 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -13,6 +13,7 @@ import { removeQueryString, } from '../core/path.js'; import { normalizePath } from '../core/viteUtils.js'; +import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js'; import { isAstroServerEnvironment } from '../environments.js'; import type { AstroSettings } from '../types/astro.js'; import { @@ -307,11 +308,16 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl code: makeSvgComponent(imageMetadata, contents, settings.config.experimental.svgo), }; } + // In SSR builds, any image loaded by the SSR environment could be reachable at + // request time without us knowning, so we'll always consider them as referenced. + const isSSROnlyEnvironment = + settings.buildOutput === 'server' && + this.environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr; + if (isSSROnlyEnvironment) { + globalThis.astroAsset.referencedImages.add(imageMetadata.fsPath); + } return { - code: `export default ${getProxyCode( - imageMetadata, - settings.buildOutput === 'server', - )}`, + code: `export default ${getProxyCode(imageMetadata, isSSROnlyEnvironment)}`, }; } else { globalThis.astroAsset.referencedImages.add(imageMetadata.fsPath); diff --git a/packages/astro/test/fixtures/core-image-deletion-ssr/astro.config.mjs b/packages/astro/test/fixtures/core-image-deletion-ssr/astro.config.mjs new file mode 100644 index 000000000000..86dbfb924824 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-deletion-ssr/astro.config.mjs @@ -0,0 +1,3 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({}); diff --git a/packages/astro/test/fixtures/core-image-deletion-ssr/package.json b/packages/astro/test/fixtures/core-image-deletion-ssr/package.json new file mode 100644 index 000000000000..ee0f63330963 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-deletion-ssr/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/core-image-deletion-ssr", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/core-image-deletion-ssr/src/assets/onlyone.jpg b/packages/astro/test/fixtures/core-image-deletion-ssr/src/assets/onlyone.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1a8986ac50923dbb9b36ad43e1af1415928ea912 GIT binary patch literal 11621 zcmZ{KWl$Vl8|(s$+rr{5%VNRZVQ~-cPVnHG;O-7V76=j?0tC0MhmVi%@00)U{U7|Z{O8U82hRfl0t~>iI<3v+1mED9%a%kB`&{sH=R@$E37SJYG`6Tz(zJ= zREDVaqSGH1sWIVGo8;a_9{RMR!xK6jjsd2Z<81p+I~G!9a02mO`dY$a8?I}e%)+1kkJk3;eh9=0-HH7!O#4j zG|s79S2CfkyG$BMf9tg_UfVYNMR(2RPrzzel!2$1@nlQkrx^-J7!j4ZrXY8X)eEhj ziwa1^@Xa?Cg-`U1Ea)!X&CI5&W@csaDk1!Bu9m?sstNjA`-l*1!HJG)@d27%{A@ZM zl1m9`W{1_Uzx<*4z{|B~0Q6MF*WxVc`+!MBTo}WuR22}Tvbt5+P`R~v6Z+AxCCJ)=}(hvfO;gfxTYUbCS{n=}c5p{Uk z>>6yn&G8vL$Cr7wl3qbnVU1cWuZn#qoLAbjn%@ssuVcz*r$FlwVuJB_IhEv`BmFvS z^b@aP{9kt$SS@&Jk^?ho-liP>;dCmlzBBtsUzjAXqk1$D(R4eF61KFGFTU-)md##%aS2{tVV* zlm2@6>d7(7DlbZcqqmS%r5qU7Ugob9$&mp6uM+<*0Z2g9e}#w)`YXhDkj^ZoK85^u z+NpG|^kYn)a-2-Pw(3ZxMRsr+*6`Lc9MU)si4J$l7MaQu!0Uo7aio&T`E!)Bs$&C2 z0{Li+n2hon`rou0Hjg;%zm^~S7_huC%J@P*TvYjG48M(j+OUD5TqRwV1QVxH`|cgtNC1@oSmJ+fK|)3W{!0+$AA*qpz?XAiK0lk4M8aSRaSN;9 zh7g@oW?H#m`M`+)MyC&2ESy|Dp>B64N5FNGE)l)FdI5xxT~n)C2WQ5}+qgl_8x%67 z7h2&0Y83f19~#G)J!_O&;JRmgA2lgY1D^qZSe$qoY8otWM~FxC z^!bCcg2qE|z1`F z=15ILO@Bm=%UgMO&&8YG{0van+n2k@5SnAZb=4Rp(cxl(Q>Cj+xQ2iy&BKzj@`+X7 zm(+;c4$;=;(EQZBQfSIVcok6TQmnp_FH3)mx(V(H-1spU)%Og*H-~w&*Y_!|ow88i zDjSAqE~Xl5fAFgNutThQzyHU@>!TL(6PTSg$~KRqXu*@bE*_6ltpD=w=b8Nz=bv33 z)u^IPUr{i5I?zyXfS+*~g=^n-u_xfal1wG+m;EjwVE#Vj{bBwS4uy~VR^s#_ktgwx zZPDgd-|^8UM1;c25r^G){6^_YD(gd}H(%ba#KrQ3A1^ciS9}LNC7fNh#}FN3rLf_p z$m>+a-%``eyx$c+$goK*-QJs*#PbxH6=py!UbM{)KG5zjh`xD}Wl4b#a zr7W8N(bDwu=GM?1{$=XPMEr$0%H7nvd@*~TqEt#6K2vPY+l^U$leABdm}UYliPuvC zA_g%NNiTYH7C#LTN|QCP@Y6DnL8}VA&56q}%+%i>n*}tBP_UfT>i=A&KS*lG1N%&QJL2+_|MpeQ9S zv&Vd8rdPulc+}GN^ggI7&`~)6>&S1sB+puaY8|)P^cg_npEu0Th7fcNfgO3ttEx1k zaqL(OY0#!5(I0*a%euS4dIA5O@UenJM z@)Tw&2IaRZY~aZhJioKsYgIh$RneL!eHeQmR8IQ({7x*1kqXdZ!vmag{K3-vL#=MAWZ|?|8+Ks6jGFC zlADPxs>u@oH&(HK+4PevehgvUFjX)aU2S}_l3b`Rf<+%x_De+yk7W~mxFo$>KtY0J zaaatCF^{aZs-Eu+aJM0g`j|n{vW~F}#t3<_-pbNbX<44FMaKHu093MP*LEtmv4GK6 z!%wtaQfY-@N@St?6*QssdVK6^wP=e94Cjn|n4_*R&F6C{M_N^HCx3a!kXsy=`Bqws zI&Rd7EWXBwu)wFfk|jfyU{N?eHJSxGzGy;-F$ok;9F7IlQ((77)e>lF!fN>zc{s(w zi*u!UIj7U$SFF^7A&sMw1;4-`AW3}X`PONg89699K}k%#<=$=YFt^#=n3h)dFvl%q zMTmbCqfl2CIkcD9%>?X$AwNc;;$-L83*nAs1Fq)uStsqLHPq&*mBwMYCOa-?Zo}QU zi7=Zhy7TRSqMx}_V|92K8Y%h;JbeCTI;z#u5wZKdY9>!9$UnFNF+R*yE-R8@_7xe2{4zYvj z!jYy91f&C;rP9@TIm`~@0&NXK4jsTX<0TFn+!E5r1tIEKq2{BFJP+6@*o>R)@9}`u zIoVNrSfu;u9;nM+ok#tgDJxWSGo*&>`T*8bBK>d3ds)DeAK>VJ_1e0 z#DflgvR_CI;6Lli3+4`S)H1MWc#)xgc7%neuHl2pu@N>3VP=zw?z$`h?|$C zA{eda=;w~}CRg+7b4b+emEw9tWN^K#EnG|7%+K-fOS6FK0-+JjkIK#6s};>`$l>Y5Kr1Vw>nUG}+5n++&o0%?HWHz{p=L>+&Kc)9*8sXun@BGcy#HGt#K_t zh3%-S0C;G{b>7CL*80bLpvkI-IB0tOz1l=J>2J8^+S&C?{8G%tUF7QO%A!%P2vjsX zUkn`es)9+h%}pad_pz9N1M@aj&91UgsT2D(-b=V#5|eL*J_Vj$j}(bckY1_N>@Bmo zo5F$8Q?7ddH+udpG2Q7+oB^~0g0AaQ1qIHMPbD_NMcIhB=_wc@amf3DSx^;jW{ew) zkwyiMBT=}k$nz?&S{2=w&;lUi6CH?QC>~{6Q%SWfY&Y9Qvql+2&#e@B4d1AKBN#%0>~~3D0Mb2byR=vBIKgtTwkzhkdB+m z?<$bX(Z&A7y{CF?HCVJ}Qg5i4;3FnagZ zua2W+#c|^9kRi9-LPB3|A z48xVyS#{_6@uxy5hLSC0A?X<~5%i+hxBnxV{Pd$DsSOH#r-M&4rO>nB;pl*7Yo6yyzcQ7%(qh%cs$oC}HKc?05V#B~ zQL^v9g!MjTbn|rIW2DV#XRREDk*nA)0R6DDIo*@O!7h_#&hgFTA8E7M)7t=vGUZlg zA>YJ0@n$FZ(7wRk)E)VpZwWMFMTYBy0JaP`7~<&Fc{J)D#j{fe&5p|STBDC-yBAhI z02kgs6Q!k1NgV6zO0#L=M{N_!i@dk#nyFWNl}4HsY&ikI?j-b5G4WjkBJy!!F93hA^Uw5ur? zD64S8uJT*3HtD+dSMz$P=HGwWp`^KoiWptE0vd1nnaV_ zX7+!=E##YDJc`^Bw-f0<$? zH6KN9UU=*T!(kG>g*UkS&SFb_!#V_U(Km|B3oalp`W3 z?~$11Mm-^MUk&_Aj_^d^?%L?Cgd5vT$7Apk?qfDmuFTw(Rnz8TEX(16 zQXNXM|87S2T3rY<9XY=Fis~$QcWb2!r){_S7VUeZBMvpA%r-i8f^oRg)Wp}E=#>r2 zX8`65_x75ULC?|qN#pP5ewU&26^iIM9qXYaaAcofKnn|4a@McJoQ`7g_Kv|^jo0TK zZu68^IvalJ$R%@heTja|{JJ?3^SZBBs)S-AsLnMUYEIASd^9*h_q%*VF>W*%pm>O9 zzz?m{b|d$}bDmxJ#=L{P=MRlAg3gO3Naq}x)|Zk@cSa-?FJsuYvFYhr?0&bp?<6#~ zAPq2_Z|(E0h=82S#pC$}W@(s&sx@3QbHyQnr-;^kLjAMdv` zjrIlBH$N`a4Y6Yb)2_6T)j)(hBk=Va$whxJU}+L91&PUn9GVbAyQ0M&;(DoS8aH#s zuBCPjG9~^M&PR;T00(WEuL4~WJyzJO1NIE8ruK^y5BQuX1-7!%}8B z%}vJ>3H{7BITLAIc$v2b6#GS+KfByI0R9YM?L@fjyX^c{_p(Hbk>_4-PKxdqtFDt( zmG%WU3U5fSOGMd5hf@m8uAwT?DfE?uOaF=VQTWwTb_NwZ?Kfulk~4D-i|a-5RpLKR z5VM^RGcF_4;A2~uS(d16as=l61%uFm}4t+&1sFGqvp-^N~{6Lc2D)n_v*PBswF>(Glqp@ z-vX_4@f|->*Hf@)UE=v)k# zBSPpKnyykIDkabLKh`&MQ28Re-PJ9UvIXLjrsolpSQkxjI5a6ek$eQlXTBy@9tnm@ z?6w4*X#@8TYsSewue04x(E~=X|0V?}=x;Lk7yl&vf0F_MJ>YLn_%|gqg8DD#wyyqf z*p~vOHr~deC6dJ@A7GqGdg%A$W$j6 zBHRQU4cb4&;Bsl#VSBjHsHVVIBMFk6s0AzkK$nqi_=moQyrkZ^x7jfb65UzyDI z`{}Bn_FR}JKbbDi*YFcV3`MNZ-Jx7r)~TR{5p7ndycl*CT=v>++Z3>a9UrNMO@*{(c~_i`*f4CdhgVJ6q*5{>aBiCp|>wJ*kQteyOA~?jrnJE zO$-LMGawi2U#hEj0E&PVeW)po_pF12X zijm!!;nDHn`-%DFB2Bd6QvNE#S=l0m?&-kCt>sV62#J~<3_r=X@HL4U zjPw+En%glPm8!BK2s$n-(ka>7ItMx6nE&EC_Iz|ufD2!l7(fw?%)w2bW<7%wF0_d|F#-p z85^)shaf{o3zOI35UzVMyr)9E83=O>3M`%Il^ltW*xeAJGeS4j3r__jVmfe}hwk&V zt%#Msow@sLc=0valX{~Rj8~hcs-ZdTe*8V%x!6!t{rI2;xcA9(m$}w9(a-j?ala+g z?!lU{DgPU;bz7M9>}>nG-5tZU)=ijN|C|Lp>Zhz(y;`_?Qd!#nxxw2#O^tO*=0#F5RV_~S5jT(=a6KMCMLEEW$$085buKBi4 zm+jKmw82;W=&1oRp+MFClo??CQh$F~TZeSapY*s$yD2v(>h$K}j0qg;}CuBp2ouwPsxhKW@;xFW03t9Pt;i4hk4Q%_$L9tn>OkzOtMki3uf8p?pcRtCq9E zEgNzJX(-H{W`Q#52W?~7gmWDr<&r*akVVF)eK&SP)sY>$po==33Be^pT_?FGsD%^Z z!Fpzp9Z{3mywebBE(6NCmwp>)c9+0uFS~T^M5Hv>cYy%W*mN0^FMsYm6X8W$&CWNG zQg%cKwS$bMBTbgXUyDc|?aEWuP7^yJolW*j!fB}(xdwyx<3~tl15auqGEkS#I&sMP z%>*}*mQCX+;+v=HxD%iK9(N~+Dw!mGG8zuCS2ZvEEA25=?uQR)7zbtqUVnOu0S(K> zKwQ9cS$JVo0Bu3#wq5!BZ^StQrLIf9g7xG z$$d-1yIX2aix%Q<%y?u_=0*ZI&3cHw##NsCwDYkxGBSqS?c4SlEh-JuJ7`+}7n94V z1BdK>dVbu`7U+S6CL!zzG%t+$=O;hR-;#wc>gwQ7OXy$+ zSXGKQ)|y<@Ee8xU$n@hsSXVLZR$i5BOs?Z2c!wd1>=&_PH&}Dbzib1!1DK99cnQ1% z#ZaRgy|Jw6@NH_v7E~L9qEC#lOuJ#!sqgad1_->#B<1OE zqvU)uGKLgq=w#P<@WCEW?o6o8x#7$`$t?KjC+NSqHhF1yQ5a&X*Qaw$LDeKz-};Sd zhqtgop}7)G(Tc8dP|bE9NoEi}NeQz(T-5R%QDKuy6I$%r6auhxCNVxEUAd(cXqm1$ z4f#zj5Xo7f-|JK0;3afF!QzLK zNL2y?En`bq6u6P=jcnd+?ywRQ$VDnwJp1p?VNi#=l!4`=?}m#{G=XXv~%kArha5@fE@}6zxN}cYD1J^kP?mdb~v7 zfbN6R_2to$Fh9VBiPy>a`vrQO5U;My1G+3Or@OlN+*U1JqBQ&bN}e$O zG=_-IrmHvzR)P5E-+eDtzfuQZqaM^3Af7HmYdQK4hJwAk!}S!mc|khLkXB!s&PPiO zBJMv%M#B|avG)$SA{$4(0cLkndx1{fG{@YL5mLjqsu_#|_aZSS}jjSh)Yq`+tg6Il( zbESVm04u^U9|A%a`5H2p8qNw2M^t>eFers(`0j%(I_@u;uR zv@ZJQz|%EgtS?+cF3|bG20M{gI;FC2W+bd?Z>Q_8{D;ozF_bR?Kr7{g?K)($TeCrb zW#Gc%s!zUhlP`y>R@!HA*6)~lPfn=f>SIWjZpqcn6!t*0H@G2FrYnp@7Njq18IeA8 zS@j(bXvPdcY4WEW`2t#H(5Bai{EU_f1UFOxb++xH$D$Y>GH~9E(A4^0381cVWz^p9 z33Zmyc7&5mwIKa=_?$7F<@K0FT>qvz1}o_}8V@u75MyOT=i_GB2AKkA>|)P=##5SC zI{oyx_Jk4806FrF8CC$vfjJ9dfLP{?k0L$6nO~!_#ePq#BNG*Rht+=%s&r<0;hquL zXv}LE$tHNr$pzn?Hfwr(2GDa|9^$|OYn08*2<(@>s;`AiYI(SMc{uBp&%PVsxu6zH zLZ!%F1Eed~g|r@bAsh7EE!yNGw~&{7>#365dg{pVEJ5bCiSfo%sLb)S55p6>aWZc+ z<984WM69vQp+-mDqL_tsv}ml-F+NVGX+gV)Ds7OY&C&_6vaX@c)Sdq*VS|#AfX}3T zx4#~!AjMbr>&OWxj!;l^FWK4LlNH5NB|z`dCaoCD+dIR;BMk$m#Kj`TjwK{%IDBOi#9g}M1*bPkil1_F%{H{A+Ivfvy z@q5@3RXuN?$z!gzqRjr1iR1!H78R%#$n?}0X*uNRzin`_VGu@w#8OA_r@J*Wfi=nB zg3OSY3>o_b(vUvYc3--K(T@M-{&bAcVQiffH3CU?X#=K&%m&3i%m{qq zD#QqaVu>!D5T5pQ%@4~JqJRdhYnY!hlmNHI6K>K8-SF_#HMdABmD{qpLm7f;={O90 z(j7E*`z4fbuC6kFQrJemJ-QgrLlK45ydH^mM^D$Z8Fs^)2B?MgEOhN$Fi)s55RSee z{46}cAj(9+D#Nq;)!5b*TXqB+_l!~wXSvgIygEyv%>#)xQG8lrb)6My?z7pew8+dv zTH9k`c;@uJbmj_PXCgg%Lx_W?PpB-Rd=+LxofS^lB(XRSwfx(DBWcx0zVq{DxaDFT zX#8pC&)^nF{oWds={!0_QHH9M#v^ec{(FkoJr5Zp;%(>>GoAd)(5!!A-s) z`<{BfmwPuXP;sbf(#(>6ZyvfW|CCdy4AZS8U?Cpndb9hbp97jC>m^~Xo|-$<8Y?&G zH&4yR=%P?o8CstR7)L5oH8?sknNXY86^@C8gRO{Po}qPX{!E7Iy#UsUQwLwYfD#gg z@qZFvzO>qG%~5=jLOD-QMx>sr^GE*~a6`-jRMATv43?S?O-Dins;*NEnNTp;-KUuB z#E!{>=$kOw&7+wC$nRq(Zr^WE565D&xxU<7BaTJ9=V2^Pt#xoN*t1f{rP<3tkVTCR z9YR7ianm1jexyP!i5%3s!|_CIwV8<_^dM8}hf3y7y?`R@a#=tE5k4_xAv2ay8KMDCJW}yJ2KnMuJUuv;6!?YEv2@`@b4D20J$*fUK_vYyaO-5MKm-} zgVp=B1HJJxK*DxT{XI|M=Os8HYVB#4W%T2A|5O3#B#6}SDr@^?U-K2;?{Q=$x?Ns6 z%%tmA+9Qj|-N5P13B0c?iY9TlU0!l8nVN(%Nf+=V^w$ z{+nNQ*06P6Lq?fK=m`~m{D50+Hz32Z%X+PF2%!Y>f;d_HX$R@AdK>9pcxT?t`g_)W zrHtbWx2~2|*V0yW46Cy_0+(lL$t$EFk;!IVCPX06Bs- zG}|-#{xb9D6|^|YfO}OUo&yGzvUUq^mA$t^NMXu^zUelzMON&6GPr8VOH+?qddNy) zgqLUvA7ZHDi%m-Lx4=WiA6`X*ZiGO*{hOQuS#)v@Izt@yA%aQDWP_g8vL{FsML)ypY2|ZuijX24GM<1?Ww(1HHwF z3^Ep!N%y@JIEqN71Ew=ZBi??BKyn%xlG9KVfl-N}JlKT#SX1#+i@faS&0B~xKrTjz zhv-juHTkz1eN*DqJ+%#73EY)V{~@Eyf*~>XZF4M(kd4+evag&3O&+okTPQrn%MT+K z^b-H(Govh*349${XJVRHoESztY%?HT9$*TEu)1jL&Pz$EJClvLCX-tdoh`I&VEs5+ zJBMc~3Gv9?g5OPYBJLo&tuCB^6o>Dey43z;hF0lp0Xq=sjP#ugVR zFvBR ztxbW|8h?qB{v(0nCu>UcunOCCBY2;eZJDm(y9)sUBn|ZnqSy^zP7UK2aE02vtxb@| zT`&BgvqH657G}%K0sbAdBP_}!$vPVL3^1jNS%_;o1!Ku3j+NM0i9;u$9iw?rU)4VJ z^tyxt%*goKrsi5IPgppMRiOR-Xc*M8Q+Vf!ToRVrSXl!WSQ7EDWCS$67pD%icX6C! z=4JaK>6P6OpF!LwL00T&R>xti>OT+~JHfPHh{ZCpOjo9yzw6gBuJn}dJxB3}yPF3sn{+eqBP0rPHp8SBH+NRkZtNg2@rM%+ z$`m8MN&>$96libLAM}q86On;CVj*N@($O_n2M`R7TnUy61OQP8$=y@AbF#;JDdZgT zeFSK#A92J|iBNMCB-ZS|L#TvxV?)zPc8wSVJ~Bp2$c@I-#u3!WVJqb)U*V}Dn~qfb z-FW3q{La6txl2rMQnF6;Uz7_DvK{|~BZC3q{z>DE_3pj)saOjyWK!V8m1MF=qHEY`j3m_JD+;T)t`82Bi^sH2c0;0c%aZ1{#RuGM4`J8f< zhP6?z<8ltV3uSOiI4M5{$ed6C^|?QdP|&=VH0mabOq{;}@@UkAjj++(u+PJLCqBf7 zxSAUIzCIe$ZRDeY3ye%y=$QrDoy-L7VE)t4n6p%{aVq4@P{%y&QIxxov zOrA|tMefVY%T4&F%fwCace?nuihzm^{BQja2^m0u0;K05WZ;$2@&FN8{VgN(gXtRQ zP+{8t88jjzAxQzyXAB?9JvdF<)zCzY&tx)aiIG-OI8FH+FAJRDIjglk5?6NOTc;!1 zerOzsZ82}`eQKMKsoPN+WShnGZ!aX%s&v!+6Y4oJWo^E`U+4}z)pQtG+>v@&XsLMA zf`)5;Kl@f|YW2DUjoz5Wn;B5Hav;l8QAe>_){;x9pB5m`Xv?wm460o*l}j_JJDLEe5L5NvDW`S z!q7x$c!mTq&cBmhTfCr2-h7Rxk+!l*3h-eSy4n!@5WlqTFx?2Hb+yOp18Q>T=djXslh5F*R{LT)@ zQJtS26fGEeIL@8ex-w)_5?wU97KW+@vG}yYa#m?G=dD>Hc{bR-piHh+>kG&Z;JtUX z9;#b~Os&GEoq`+rGdU8;b6iTS1C!q7x0QcH&~UR1aK#>1*nQvP^VPIJq7~A^9h%P{VQ3InJNVmxd#PNt^_kdEJDGD3$K=LzLUopt9yQ57Ts&0#GgJRp^uWJU_5W2~k&p>^ zPzdQ|Kw4lrpa%oYs`x*3zJH|dJU6>Yegy-KReYKM#Z%2vOqDrIa>*fNKQs=DJPg=W z87}K7B9tT(aGd|UUnaLm&8u~4WlDzEs_4kNu1VK{<|%QFM+P=KIO|umTLFt->|W~=cOf4DJ2i|HvaPjULP zMeX;JbxfmU+4L)|KQNAB`5#ee-M^WYGeMKq zJULXEMZCw3n;SIV=6t*6qVOy(@p2BQXs#+gc_TZYV!MM;!%)nrB`g_P^fj4V*~`|( z8Eutn_ghh;V1vBSSjl6C9(^@qhS%Z7D6_(z>F@Q!^#@jg2(MpUL|!pl{KjXbgwVO+ zG98=dcKJr5!1&W*F4i9=zeH4$o#GCQOL2$dZ7KBjcX!`? z&;5CmJbCkGvXeJDLDl>0x}8; zDhetl8X6`!9u6M)|11mK|~R3nBUA-n}3;vpd6A^aN#P{DabL4X6`{{RAO1BwC6#UTJ@ z^h(6yVgM^E;wYXZ3L%0d3V;v=Pm)j$AO}Dog4a-Rks+i~Qd*J-Qj(egO$5#VD1wnS z<^FpRk;}nH`VR#_0iYmg0suq+0K6NXBxF5Y8crd_NpR2+$%VltYFO1t*9n1xFymUv&lu;nN{P0fXVq zVE8cbR%9^vzZ!y;);9oxq$UaiApk*^P)FBU|0$fsT|zlI03m`DoH;@`8r~Vs z34Aaxp`auYfCxTefTkRwBxbpeBX-uY3@rk@7cQJ5Er1T37b#8nl))$fpb7rnOHq-I z2-2lrk6KP8FfN1@PAe{s5*-LAK@5SXk}x(CBJ)G#oj@X`qe-)_dlvSR;yBu30N`zL zWd$NL05PT*7*-MjsO%H``?4;?v(=+ZmH&17iZ6{6zFz;0D~BrtC#r=4p9=uf`+MQL z3d*hLYih^B(z)6(=0X}`xX=&Rxi5j&6$F9!}%XW{c z?eI-NAe53t(87hU9fBqbe515*_i90NP5HF#vi= z3^RI3F=9+5p14@P3iYJr{cMcyG5^f=+NzDW(=7lI0MGCOh+Yf;#v!r-i-PZ_F8={C z$GlIdUjrNK8g@<^^Pi{RnGy{&+Pje-w{9M;-Yj@jbk4+u07G|}X?qI1k{*P{FuQ-k zvZsw1k6X>$uK!dt{Vmjed+dLbZl-6~GOc8`A(+H}VCiYSqF_fEcYZ>m1bZL%Y*VMM z+(BSAb0r3WB{*E1dIj)LXDGbo5eK6MvD}nB#J+NFtt?X8AAssTisI{`Ee%WCs(CM} zxws6v?x)4N$mkjbzM3f7VlYoWL|>@eO6o43~;#n)}0UV|+6 zO`d0`DvN&C{O&O6ft>^@YY_Bc57jJ9Ud(u1J+*$O19?1_Wu0yZcev}t<*M{Z@VV^{ z&#fq&8QLk^`b`)qa8f3-OxRQOWxkq3ZYBrb7Py%Nvg`^e)IN!@2liR*>mGW34BA>0 zAI@RpRwGDp@I6D;TcqEfi9T%AKDIkAr?2DOc2avN^Q0;r!cy`tsKys9-Dk0R61kT9 zec-%R7r-#7LZiHPLX~LVW9NX;V$fT%GW;!i2cK=ck$J77Ib9!yy2w)FRYzE1IU0}s zR8$a2)ke`5ji^KIgmz>iba<6z{|}(-LxrgJr}~)OyX9k7z7Z2a5BF|oZe#Imw$YZA zFQeL5>yPe9vHq5NTNjkF+i>QaJnOs zt}?A7cG^hQiC1L!D6MlN*FfG4#IQ1+PrznY6ZOrJxpucD^|$xTPo-Ja2AjXxTF1Um z%&!bvrPXG(71_0C#-F!7t&H7K7-6*}@tw|2x#d|}W`g*HE4Q|n4Rshr=JcE_F7D+m z$7{a9+>z*SlVf3emX^FfMoD_uwnWyiSfM6q=}yNJ3lh#XkMHxlREs)gSn}&8v@Tlf zc6<0`lSShz@?bUSejHf_|dfF}X#rOHmGtuX1#x+9+aj#oJ zBF{OIWEc7jOm3P=Q?lFryy8dn>AFmWr4yujuCqG^UZ4Wh^ zmzO6|Q>|YH6F#MJy!AcG8|CYElI~9?Npn-~ycP*I3jH-S$z#jS=dF0Mpt#HAI-wj~ z<~d@R?Nu+a2y(E3Gb3heR@l6tE9QEbZ|FeGmuGETbJeu%dojhNqV&DY?_G5FYf<=6 z_Xj?*^soC3e)N+BxdHdFpTrioJU@Dtv3*sc{v)tp=g}LrY>zt=S6BWIFmF%(Ex&kL z0-r--!yMLRrmNznYS+bPc`Fd5rNvtz7Uq@YKB#_Rr6CPGm=xH5poX*KhHbWaYB4Xo$n3QEylhK9(xoe>u#!ESFGtv&yeBFs$S!* z$q5@%LXajk`m&lSR=GD`+K7&+<3r}^lAv`pnd2h&Ne_Z$a9nR#P zCl%TXyS8nn6x0VijAD!=&sG1O0j|CfJ!b5X_uNy&HgG+SX}mKY+z^u$IWU)pX`iM|OUD%uAp}Kz753U#}xprqhvZ z4X;GvU-AzhJi=UdSk^cCJoBsG-}8g@%Pt9}L?DiSUCR!S>%pJ7H)gvZ@kP&>f=@Io zeW$=5P5cNq+md}rn(cYkMg9RK8fV8^7FT8ecFkO~2c`V}NHtq>o8S+bh5m`m7wn7c zK~jiU((j|2{Q3`I_Y@Uycbw(%ODU+~WP`?h{d=LvbgRGvmq{Xb`R}oN{pXe1V?O+* zDaU*2$h|+)$UgiU$KBV<^_Tv4A8_x<*8Npb_g4GJKN4el?u7a}2nwosFSIT^QOf_` z*{GpYn^@a?Dl2P5Z`|)41wT5=*M%#Qw$JgY1Z>9beAqb6(lxww=a~Q7%adQ7Q(u8c zZ?LX(#=Gq|x?okyM{-O5)>Y2h?GEp5${4JtQQj2`++hFOcpM&O2X(wMabxi3v0Bsi z+z7-KbW8G>YrDc%DDjcXk=LkU7;LjArPuNr7=8uxb0vb@h+`x%D-# zSfZT&mj-i{v%T0adPDD3pL2gvdM{hUZa=c~ zfANi_R#oJ0is?zTE?(VMrIl8qJYsjKVYvA`&8NUWB`YkkhMUCi9`wCPSh z8q(jsdj7$8ra$8E2LbE>=aJ$Oa-7?_lo?ZA=P{#{f`9RtgK_bFEKBlm z_g|Gn_OgUJyW8J8PKO6I>HJ{<$wdv2fQjBs=f+oicb!8=imqB??00L4GU*(w^_*X4 z%wsbiyDnw@E;6xrC$mo9dRnsnc~*G)-pbbYvN?AP<*$$0)rN8P&orBw{ND@oj>>;T z>J>lVT=*P+aJO#u7r02@E^TSN*`(lr2$(r;@)x!T&plXY?H><>vuS-gcE^CU!4|Bv z)~u@d!5`mH>B8*_vj1!fIKY+9{|^ul;fBTkv?&mA;cb5a0Oa>5p?Dq{h{%m8?-BJ@ z5&lyM`2qOwQr`n083F(#h9KjKPCo&Z=}i%EN5e;FKQx^K@H_ya1Oo-X0$z4Zma8=7 z)-|P3#Vrqqva1E3jY1d_%f4kHYGlqPmu8i=!M@^IEGXwqTju559mg3P-aQI`XFN>j zp6HzEbjkl-U}fd=cz8Hkap7DzVauLg4&r0|I2qY7^4GY=w`2c$ZgWNKZV9%{R#+`6 z&?hA1aXec8K?0}cqKY@|SeIP>_oj(N_J;oOEcIGZkZ4f-lDp>)yL*q%@baCQ;j2k{ zUtLnbU4HIpv_#W6Y*cN}K9z6zn8z5~;2x5;XH9u`Y<~8qW)^Z>cplljlAc{5WyZrJ z`nT*)P?AyWajZZuB+-}vsu>aefuuVB+_|2bs>kq``*l69z~Bj<1)aEeSCC1mZ|)CyHu?gs&^8ivl&&F2DCNQNP^z+)Wx}7fJk2F#sU_ z-%!es#f;MQc!$r*redkDZZC)N}RKu!vvD+VB@qzv~1Q1bu) z)DTm~lsWhd0APa}g+<2;0Q9pJ&%WaY^do#_3_;Do{`1VYEb3%MiwfX{(*cOI1QzOl zpihti04zcf0i?|jOpcHJeQ3A<)TjeBc!!tL`uHxvUK3K(x#6=EjPj^;ss~+f#zTlt zM*~*BNOMk_dsYi?QD;w9cBxQ6dt)?xo-?btj8fc#ZuhDDl&Gu#H*6#%!+?d3Dm^RH zua={(dhAfuZB?7{TRhmgjY8M6hizfINVfoD)tm=vFHrm$6m2YO?q1imX1V1n`pwu~ z-2Mrr9_Wen=vks~>{ye+wMW*z#tE%hZgaNgvIsu}MRoY?GYd)*j>!Ie3tY+J!c{ z8j+7U_HQWXsix)O9)ox5E1_YE_!MeE?-Bld#AmN{{eO{*4t`V0ZmYY>U9YJ3EdqhJObtJGMC=q&Q z7KddZbzA*EKzJ~P<8d;e4$>~`dG8^;*Ei0SI+N#}y+%;R-Anm6f(p)=#6V$|mUBi+2 za7?+dW&25=wBr&glbVhvg8ofvU-Uipc*5Q>hu+M>_qaW7^N2b|>jcHAA@C>C^aFaq z2D*JDb{3Qf2>=6~#UkOT5WAX(;8YZ8Y3y&bF(ETqOZhX!bI}S*)upqeJ>CX$hV;}7 zm)jYg0f7z+_5&t{334Iam4t7!*tA&EJrRDg13Elk+5F~2%iCq~a5xz?TB{LF<(QD? z9PmC(yRxKOG8Zet9#RS)FLD;&pJCjzW^L)??Q!XK9}%Dlrc;D};u=3sF!qwlWqMz~ zvMv86rO6_*%hS0bYLHBdvk*Inh@5#_mATD)^JXFmO$Yc=vlw;hNb>}~ zJ!<@NgUY1#<8_f)jEA3-_EcgfzUzw8`TE# z2%|I++^Z9=dff4Ia44Umx?X?1aBY3c$7Y@m(-{oR-?{UiV%8dm-B!7lCm3O5diBUs z9p!qP*+9OZC=cI&Uor9O&|I-Osf<`n5Pw>D>07m`Xk}IZp*PChYy8oO?Lg~&`OYsT zN*9GnyT#qGr?z7Ol$xHN9%AghlYyODeby;z@#!<85Pl!>09}!RLzWOTB*F3P`kQEx zqSD9LUt1K~(sd(>KQvE8p&Fm;XWT;V1!%W`|7yDbWHkk zrlZWC$aJ+^T}2dq^t~`JV%2Ta7Mg;3Zp;>qOr&x3PqWgdX)Br3fext*t$HJmGXNqS z#jN>y`utTq_TT(@_aFVW)bs{mI@{oWBg$HGpeYn>NWuZxf@o(cY z|L~`x{KVxJF^7%!EZ#rd0-X2F3frQbyJz>@eM$SN?ovkLld<2h;jbxo)Hase?r4!~ zETiV0{y`}Zls)8fG5$J8wIi~*yk$Nfa-F)=MR$A~Jk{#?IdxP=uKIBdy*euFEr zp#2X>nS@V1g0`uhYr3Px&Y;itGu>N>ss2~`n=zGugp|;AYBWI}PGdBoclVxgKU9CF z8Z*Sw4oX;BTdhCvv_{pwE5eWs*P1bp#wwxF_w*}fA~CJ=_YSd>FzLCK3ATQpd`J^| zN7k;&uZ|Wql>a8fp&)kpFi&m}rlDDFsuCUvBhgZ2c}SD|U3(5RmFJvs6zVM>W(ea- z$m~BxpJC8mUhYZEqFsTufvJqv8#bzu(z*EA6MO89TATx)UbXfl zGE@~3+f@%NZ39Kf2-**nxTCb2yB@9tv{aTrsdfO%r7%9XgfJo&#IFlRj`Ht%zI8n8 zfXKnN^9J-b*?afZ`awr<_mWySTo67lzY6RGp5w8YeH4yu;}1%&Tt}kUH0@(AV^h9}b(1O|koAQ0NXq`u$JFtu zQKJs?<^r4ans;Ou3B!uP59GZwW1pJ}L6_nae40zvT38Af4}msE!Ekp*;@{z4+l->a zKF7agNCfPCDQnSZ@c3$QL| z&+{U%!++BkGu`Bq^MT^}cWhlh+sO@?YI;EtdgFH)ga)xP)0}Do$LzA$nb%KyOYtDc z%H*5V(Lh1dC%mvhaV^Y|d;{9()o@&X!#A|?U9Hym51TmQbTFC}L? z`vn{*4^*iP_IyWJR)wQp()u>Bki%y{ANJ*>QD}8q{-d4!wy>5V4*O}Fhlz5r-oT=C zqYklRR&-TsR8vvp$d~{&yN7((RhVOZhS2p9Xl0TB#O6&J4!OffsZmBes z+~$s7oYY=0V)sm^h1*7|ZMxX!wl(U@ZEc#jgbeI4G4DR3=a7ySb|?Y?daB0FLLEk7pgWfo7xz{~ zx_u1)1qHmv!^Os`nPt(VZZRBhjk=h7h19k?DmczR+KoMS^HybdKe_OhCpk8AHtM@i zls?19i^D0UL^~9fr11jg-PR4Cgu%nvi=Q5?b*7`qAvAaBFMs zR}@<~m)SBM+2jC>9qxxrs3^$kTZ`ASUX8{{`UkLHkD82HN+wNgcikUBN-$@9S!`)X zM6~mJj3QX_X^#!7^C~ZcZCyuM{U*KrL61i_zp?^Y1!f>i^Oqe(&3`JJtj2l#0X%RWJSk=QFQ{pta zi0rdpzkVwD2UvZ{?$woFaFDzFAt+QmQ&h)Y4_axl;P{})Rs!Vf1ev*58THO>8Z|Us z#9r7Huzj?qK@Lbfv&VK4dcJzsP99(7k#EMYcK+?yK}hog<}zef2EUCFd+tfPwWbpf zFc&F1Z!o@YTR0naZDknz2QXjDrNupsh+rvPG%be87fg%Hlbe=zxG0d?-Ip?ULN$ca77kDQDc zGOfP}NJ02QtC#|l{x!{^UJe(4fP{pIgaQXt6l5g$-52~m5ec7;OA7g| z7A^s|nJc}tc4#UJEzhSfgq$*N4eumDrfCD3=3zf?h}=u&QyO>wx3-TY0oV5LJ}5*C zCC>hm_V4D+6Sr%U&7&I{mzw5!kOWz(>Rl+CKfDmANB>RLh_r-h6rUBT)%cX2La(XZ z?Z?HHMKw^iQ)=j4Kt6GD7Y-Stp!c2MaL5j`{!CQ&yK5iQl;-* z#oU{M#pijRvFa`jBO0B%&|Fla-p>a}&wO}ozOP%&voRTKiQOq$(1wdw_tR2tX*CPPm0BdHj{P(3-OARUMx3~on_K5qjR+N~Q| zGHoRjm{J{fu^H2^7>_b;(aS&VprZ-K7KS~N=!ovjzUlF1R)5PRXUmD|0bqPe>{Fb0 zOtKMC4iwOMkh*f$FUKTunN0{D9ja;sry`iM!Ba>PNJ0)Sj+No*c zE3!FPf1LzhKWOb`9xN2#q{Xt-lGtDauWk66lYJCBii}md_FnNydY%f6_9EHoy*j)T zuotfh6>Q)7Qx?Rk%LxfqbjkiWp+h~>tTZK5Fnj&!_u-u%Qvie5GgF%-mCl-i#cS-o z#eo*38o%RK<;-{Qh?y_m-I$bF_%Qpt6BzOy|8H8`JoP8H{9*0dL6O175ig^~?NMy2 z9OgpAGZ>Rgf`freSIL-TgRsN5JtRs@LfZbBGMR#44Tgx76V4FTZQRul@AxrB9heoy z4K~Za;4XaW(K z!}3s-O}VQGXXP}G@=!h08nELQLRp~sRM-<{^Hr_CG^x+7651uz{FPXW6d4nx6VTOz z#;1WN2%BTqYfhb2h$LX74#Gq&?{&{SulS%i1NjKXEWU|}OxpnIAIWYivg?*;cI<5B zBo}{WC_U!t&Zt{DP?I+Jcw(WPM8lmi#`vt;6i~hJifsDfaGlH@zZ%1osj} z-(D3guM+I%&|Z5H648k|6t){FBT0uBW}G*auye~+$V>Cl)FAJ6S0JmXL$se&JXH5l zw$YeCLSw3v%J$YQN%>`xR0R3O3JS=Qi$HyL(B6QI2O}VpDPIT2sB;XR{oK{-C0e;4}Z=BZkRMxDT{GxddC1u?!rLEnL zS~UWS$AG>*b@$t(6^`fz>iTS2uy}owZnW<2v)a>p@{xKWx}dMavyDzTX_(NuHM+*! z=qOYjP)K|ht`)PJe@yrt{8wtsp*Nn^2tg+Q-QTz)f2ybpGfb|%l?%VhC7Y59bwXU} zYnGUPx0cqkh8nibqb7c6w4Mw^^C+jo)ptIO`y|B~`=q(NbQB|`RjKW%E{^;hm`hT}FtU;Y z(=Jr8JM7U6j+fo>6m~hd4gz{5+Q>>^N^zUoMOfg1#BHdSaID-4H4hKot_VJr&$YW; z^mx~#z6Dj0ExkpHB=KY1REku~yNEC0EF0)7AHhSUoYNGJ(XIkJI-!LhuRZYJqaWe= z@<89OQ2cXX~mC?)(QhYl_&#@3XF<#$g#6O}nydfnVoOIAe|UBn(ka z%^tGiq@l4?7(v)-eM0hBM~Y&y5f|qL?Q_ZWQfp{U9a&|CBEQWT#Xu)gPp@4n2T!oOG$?Sv_(pU2 zIhWS+32mwMTZCqZ;vtf%x$2A-gAN~s^zPINr#tI7bJ32V1edH#GiBgKRx;Z`CWH7+ zw4wuY-|gtmgJl6GmgJwq&=3^^V4#4%yqWku)&VGu4(ThsI3Bv-ZA)X(h$rhuKF*3M z`m5V&3hZ&U>S?<|qP*&a=q~1~+uR}UvrI>4ZHO=>MgAZxul{gg-Y&7Pq)P+Wz$6vM zn`$PX>CbxdS0k@CJcOY0+m8Wt@pr(!Xu`t%ii(o!Uv!$-K{WpWOOF?^-e4y*7_XZI z2$|>~V6q;}(fm9};_I&dXE2aS_3)%4j5S+-BANgXO9uykhEM#V^#oH6FTAS!2jDJ*bbpu8H3AAJ>hS(%%XblGxy9&)pjX!=DTLP; zTx0yKkwyiz`_=l0(=4%ih8yjbX-eHIgznjCy=xbcS@KeVIGNl_UEzio{g4|VXa49-r;%od!tz1DA4^S^folJEyQX)P4Djf*AG?0d_+%|WBtJL2p zLJ$Di03zEgQNm+^bA3J8@&~Ymo0sZ3qayYkea3!Y_5#|pv;MvoS#Q}cOlY7iLZvebz9OzxiT==?Kl(Ft zdYqh4@0j;TqV%jB%-i{j)rY1cRk{M1_2Z>YJL2{DKS0s=8jFZ+MkgpfS$Ijw~>Z@uU=$&WvWMDqF}|1!R+Yue1Xj;q)Lp`jQt} zVh6z!oqAwI`|5nCK*$)Ud+Cf)%|QK?B3`E&J-5xmH%2(p^hP#OxWpNINAqjfGBKnD z?jaW@niiC8XQLl|4ubX%*Vo}3Z-7GIK^G+h6&@yW+{ptz+&Pg@Rnrl}0r;e&*6Y4rv zwd~9ZHkjp8t0m#mX75*R1U7BscH&lXQfxAS{sHg_Ot39w_c#wu!*Hvh9=BkwdE9ED z+m#T4(!&1oUv)9y4Xm_wanw|I9KU~nNN5|`Fu$R`>Pf##SLeV69?)26%k+IFo#I96 zS}bVykJrbnAJs3KYX)-E@51;wr0+gPydAaLjwAMfFpCPO$5RTZSMEJ^d>=I)I7{%Q zL(RC*b}? zt3jC~<19GdRyB1p6RtE!VssKm{4qu~9~Ya>>KKrDX>mWPuh@&K;0=yhI?lo5%nNW- zeG4*TX9pG*Ooeqt0~1JPFBw1-?oPGnRLUs=Z^{XqTQ8si42V~6n*|AYrm@1H!dI!RHX$75R}2=(ZW3mezW96S zoyjzgDL+68R1*1cnQ?3@Q4%o8yiM?3#J_?Erh!N>8wj|Eg-`l`ax9iF+5d;%uW2(=?=F|5HN^`+!MP z_~y`A;Ulmf_x0hRW7|e>lb3ZP|M-ci$!T1>RqUHqe0Ewv)0*IO; zeN0kqYjn(N^XLoqTHPf7Wda`_PO9{@oY^UF|@Rll0c zNfjEJFbCKmh(WuRoE;r*Nz~T4HR&o!BAz)-RH~0JPFVuK2=C??e!J8puUev==ZaWi zj96?$+mZsgtF)oF4Qe4lJPxYVP|s#U=l!jo*bjioxNDp_D{h2$@yhlrD&-Febq;m} z%>2hn66jkx`+qej%(v_kD8sta(Q=^^IX^Q#WYxU^20(B7J1jW|0Tdhmv6+rH&c6(&J(|FlOk1mTZ8 zp3Soq)#h)nW31SPV9Q4{IOBn;QX+;^AUeCXOw*#`o`X?$XLSq+tq>=M1D+H{?)0kW zX_+cxyV~GEhu_6#3O%mo-RTCwlo}=c@rEmz-vyL|vBn_rJ+sr*Ma&abri={QowdczIwU8wNT zBeH8Tl)Rtdjm5cP%cCA}&u^?z*lj8Oo$|??iU4>y2Og`X49C|^s|dI0Vq$OgT&ok!{%z%6}K!sJ=3 za|%6Vt-ZQ3G5e;x7kD-qgX{m@m$Lp3+qznw!`J6s??8=loPc7AaPjz@cEr31x4+0X zG{fD6$aGrmLh8F}Weu+oHzHc%%3P2!#L)NyTpmM$%Lda58jFjQQOJ$%WN|@Pjc1| z3~1ArgnOs)&=`-+`&M$JR%+WP%AeCb{dtcKKK4QBkh?w-{*ug>KBWlx9Vwi6Yp8{k zchzAiqi()@sFM?|V?zUZR%tT1*ZONxz{)F2B6OCG%g)~J3kB?@2gNqgh4vPveC2Sn z!jYqH zcxZ;6RrRb0T59d~15~!kA=qdUM1pAmq(GQD$C1nHU~*O&RoqCh_vfvN)ylhz_pC_a zVYa8qyldjVDCRvvBV>g3NW1(v}8W`*rsj%kEYXrsc<|ufcL#Kj7a?#ddO+P6e*Y_Zm_% zP|0rZl(oN3*+XF|cA3t8wTb@0{RtjS#iimY=VeZ7E%HbgjBzMcdt-1F^AE75$0Uox zvV|%r=tL}eQrdJcHy!y-D#oNaFf$O>xkyVGdWp@)3Y2Aa5g8S(nAYaVZYk_ABA$SuJZoYjsSvgoOvWYjh)R zC9m>Ukhg$Tn&E#QJy>RABwy)+Viz2SEZs4j2$GoN0~XJGij+BQ3(e7!iJ^gbYUMxH zvKj4x*=q%Yyt|t+f3Z%Fpf?aI?WW1?OQqLS8uto^0%jQ2AO#JSecXh$du}O%vVX-# z$)w3&+1Ke+@r}hkq|XA=u9!UxQBBG%5SuKqVizV>ZZa)Gq>67mk}NwdgEp>P4@y%e zV#$Man?M73#A46>_(-*)MgnF(9JQhkOsP^{p=wj;ZKahSOqwaoxd(q7n1^50 z{Oo6oaQyzo<@J!$Z`Wm!9k1D-(q;icGC#=(=5}jZLGoRY)2icc7NFm4ElRkdx)w@`kYl2%B(}}&&;VqXuFmnVQt^G zTHS7^UZI0wIuz!~gt{D{AxpY9%2k&XspT#cj`(#k&QX;;CVYvt#skk~wQBN)qPEUh zky;z~LyDq$%~8rFGX0+YWC6kEo{SNLc*Y{#px0S14ZBYS6C~Ak+tHpR;$C%%K5zc1 zTDc)IqcgaLi!3U?QVloF(Tvkh7)N!lWLVD8ca<6;Px(*~L@RjcRB1x7(z}(Sk!3t4DL!Nd(;SOL=mQ&d^ z+RU1amdsP@x*{SG{LwZCRilJqU}Rur1i5Yq1Bn>(^Ob77ONf5O+x{X8Db=_t40fM4I)6@Q7PU{Q^}Lun6Q=2SNj~aHlsKfB z$A(5H-d~AJ@rs6yK?QfVHDn)E8wM{IP1Lc5gw&j*IC0sJ2H9QM?fTdUPMO>EF2WNs zw}p+}BFB3+4LKyc`0@;@VvoD#QK$G8kMb_{7(k37-@DxGzL+{Z164}PW6 za#l}m0cxNBGVyMxW!MUA4VoXqm=|1x^K~(F_BL`fWJ>l0S<8~m=(l|4C*z=>3Yi4& z-^7Lu=+#)&Lm#V!<6WedI4ElTn25_~Nvv#RsVi+Ej{I^^y~>XqlC^RXTI? zVl^*$U`wN!mZuWxD1P4Z$0-T!iV)vS#&%BGa-R+exv_opUc=V9R{|@RYc5X%YpR2{ zW3ucYr_PM0;CXe+Aj{v2tsWKk8Wp=-!u7w4N(j_#2Sg^4Js~w46kpcJzpa0-u~}tm zr|^dsDOcJyE9hZd9r+Fp18xHe{%Rh^RBrl4OqriXEXEZ~twY3VA{%4>>RK~J5UL42n0OZ4+g&Fh~wxHBQT{H)I2iLS(@J;n6rL&ZOULw)lbCrT1L=hrnzyYAZX zLLiepj)NC0I5G)>U5BOq4?u7asXW_=*7*l;DM(!lE^!>3@V(D?rMl5?6(-mkC0;Zy zawNxq*pSw$-6tP8yrruX%nU|d^~gGlYZ{KNVfncye+=Do@@1#%ni)&S%I2f6A|Mw^ zAY2ij@TI|b|Auy!WX!?^^G*j!I^&OY&5&S0F6*|nc_|1fuWnOJeQ%`usWJrTL_ ze?nMGl#?K8G02mM zHoJw`+0*FhBH3TwLiEEknOj|`UdJ;@g%ri4JqKCc{;+~8Vg6se9XW(f3Ux{gE;>du ztZ7DGaWJ2QnB0&+LLvxmF?OOTj zke>(2^#e=1zn~glZ<@ktzrzUIy~s1MLpVgj*eMk-Z@ePhaTdR|pXgG7Lsi0|sE30j zRHh%UMs>Cmg=cj$qtHkPNaD7fd!Eq-up_(Vheo&N-a>U|S?MZV*mfG*{b2lqMPjwHvyxi+4 zeYU%wrRLRIknuM&9|Ms*U;8-Yr#hd1EXUI?88teYt4&ly4ehY2AYmOFTnlAUjB$ z;?Qgym1j1CH8PvwQdQKfRV}j#oKLjDon@n1p4cpjfdb|tA6}TE8}t?>&^`K6~>0t!=IyL+K7IDBA*usY zN%F8mX7VgwY3||O>S%{iCGEYgkJcKINVU9%=@gMh%pQx5EB6#$1K9o>P-mX@4ABWT z70g<`#VY8Bq8=UQ^eYKEo;b(eJV0$5zDXlXG|0hnJfyvo)-?+5dd*;=!SlAT&lD4n z+nom587@GZe+9*Et)^Je6hBB90vq@H_Wgw$Hza1ZowOHfTNZP!;*=-B7Pyo>YT4daDg6-6}Ul zI$R$l-+YYJ?ZGvx#JHy9U={*R4LXICKwy9?C+`x*mAQ_u7X=2Gu@Dx^K!59Tb7<%U z!K`F=Upq0DrQx^m?m?RqT>Y4&F>*!yLkmAHcYQpn;gC$CRL++#KhJ0cl_|Ubr31~ypLwhSYmR(PDNwXY=g!wLGv(7KXI@VrY|lwe z&hOTdayH@40VZlEhSgQ&U@}_V5fCXx2HBLHv=@b!zC0Bw50oTiT1?k#ZpDVcPMm@r znA`6od7^uSv2I5sd;N}sA|QS1hl`tsdoWnj=NM3av3jqz#j^{{@B2_veT91nsmP6} zz#&AK*K;9!G;nFiq^!u{=rEqLX4NXxGT{vOlzLU+Z!PW+rKsgV+l87IdhC6vRizt!g3hSi5%U338P>G~>x5d5* z2kn!|=IEXE)K7c!4k^Tr!!X-t&8vHtCGS{jEnAAv(mhozg)Bhm#PZvgiu7EE5r3F4 z?NLX;XUWZm@_D6!%|E*CC>gn)Tsep^R#`n*vb6i@f%`=>x2cZP$cMs_(xb=UILzsR zi<1$r&|NJW}h?#IF7s=WJmT}pCuhXd9_MZ4% zbCx`;wKj+A3Mz5cI4LMv(mOq?bBXx0!YicZ!@Vl5C>r!`b{G${rG@=oo&10)%(3DY zB9MklKK{F0Vr_9lyI_-#>nll)oB0_&4O5AkppPB$onRl#tqNxG=vF$JA)S3l1`}## zm5OoRejPu-6V8lSuW`3~ljo(gb3GiQn#|q)W1BJBATticFzcdWY@BY*@y`km&Kk@$( zbpHU0%s=PM2VBmX}Dvp`J05ACjH z9Ta#s_T%*$jg@Wx0Julat`!A5tGRYlU}ygTvw;LX6XDaCX#+RR%m^T^q1gBqTya~M zdj9~?0YKDv2=-x1&;I~ZB|&yK97eu|iKes5D8riI-?%_SN<isrWMeh|0Dp;2Tx?95 zRr@m%wXV0+OM2_c`IQ5)@d;y6`J8|@yHU8~{{TUB)L)?g05YwZBZClS*D%94iP`aR zjS)i4f8I>`?lLspYn;{orX3q9%G)oSgTslb7O;7Ws;YR1)7+;s5nI9boTk-H<10`8 zfTliL9zhl%&@T)G>x}> zRzJAr9hj|Z>HWiM%eRYfUfDu(O&`QWE$iXDN+V-7^N&*MKLBA2H?21*#1J}s%3QrQ zhRuA!VLX7{!M^>zPyGJHiv3KTLbLw>dX`T#+_xN!)Tb=MnT1uav}FGPyvo&uw}<`D zm{S1$ZZ|Y;O3cH5sM(>~$SFD=P^8ii3{+ASgNObmC^b{Lb?0eh(as$=lBE`=lE?me zdBJ(ZKlhj@UhgugDRl&~T|D9i=El_CqeDqY%o5RJ5qBLu58OV1?1hS9f2>5h)745k z!Cm9rI$j@2HlsdmUFyH>!wjihPy6TQaQ;C}NOT^ay02e9T8`QWbLoL%j z8C{Z|-%x!o!fLDTY^@crJ|&JHNcmFI;T5o_B-(Wd)vHfx{{V@cPMh$-HX8hq0qqy| ze=x6CVW?}f<*BvDd4;BJM^(f?os`!&eVTv@$#!2SFgwUBW?rQxklJdLIlQRQF#x1tz+67X z6+c;_MsETcdowWvD?*{H4P+6N1@#Y@P7V8(hUNv#red!Wwlcwm%PUfCfz40wWC7ec zm3HWXx>y>5ILr}l5V#o1=;kKdxxzq;Rz}Se2Nx*7x**)F;{zTh9Z6v@R}pOjW(5QR zbYF-EwHnY0W@KV0)?pB%@g4@@6P8-`yPO#i!?bjiVmqDh0va(zhls>?m#KPa%gKlI z$__zC_djtnY~LB1d0V_Yl%{R+If61=C!_j5GP4lEl!M+b42N?NEEY$iP$Aav$=p^f zqpgv!IvwX)j*GiL0y5Y)xunu1OatJ7QHZ9qdyC*>vs7x+=41bv zQKDLTfLk!k(Qz-45#AT6Npe}72_zn-KR6Ma2%x(IY(MVrQ5AzMJ z6+oKdwLHoetHi!`ewmsI0XX8|pZ@@$A3wqkWesZP*QY5fi&wSEsyHt-Dq7m$sM)JB zY(@$I1u=&Y{Zg2R3mjJv_OGSyap`3^zDj^;q4gAzZvOxgz@^F}IJ`ov>ZTM^h~ZG} zf}&eR7YAt`&@zg6l+xlrBLI_2;gf^5+li!&>$^{|ih&MJf9;X!nd^yWJ!@hb&#CC#h zfJAGC9l&&i2h7KkL)>*joJ0q1rvY9OLs17P9n5kU%NuzBz^G#Y3z-TfM}wZvajMLe zlcv)?WoVs)sOd`n9Zq;Bu%EUOO6RMwnkf3*&vJPY;pN8T^^a8%-J%Acvv6|K>@dI zlJ&eFgt`PS;A5ClvB#;IAaBp(twHb*8eP0Z0<0!0JJN5crtEwe3cB$`Kojt0YpsX-e`MnHY2nITDhr~5$z)I5XRTpEw7hxw_NuSo5rSt@xkSr zTH<0S4WpRco<^E(9ekXSeOy=5{7$Y!@<3DIwG}L1sQ3@m8;O$5-~7bNgJ-^Prk}Zu z*!ZbbUK7kIRtKEQ1j_MHU)t78S)?rJ81CEXu)x2-6AqRLc*7iGEAcQv9TI{1BYb~R z%8Mo7p)+K}P*C$Mn2;PE_D`zZ2RMJ0hx;PKt-^ZO$NNoAV3u~vm;jZ6a$3}aU9%3p~cJK!3`I2 z!XBJTH!H*oec=;$7B-|FB|7*Rp~)EQ93-|=;T@Sf1h5owT}NTj(pc$~(=JU!En%yd zfl;0pn6dRg2Qt@AWiWb~^A|Z8d5r;iMRqadd5OShB4bm}32Aq^gI3ce{RwAhigHI^ zj+c%hLvFm2EU)D(=Oe_@HfH|-#$ht#J*w*asz8 zc&~nAReqPAxPmZmshx;WGbbw;_x{OOBHVb59he3<7I-6c##*?OAydrRdNjZU;i$$< zpjM&jf&#K-&X+V3i0f~}z0mIxrw_!-UGP8(Uz9~V9!W(Ko?#K#A&tewz&uvWQJjJ& z)QU}E9wX>b1SrAR7? z%`%)O;v38X4}@L)fR3`w%3QIN$}y@#(F8WzNW%{~^DcD+{txiNxUiZoU^1y_dI(!a z3Xa{>9#yY#18QxGxx{9Uq2TU|B0Ry!RVq)|lE9`(`5u4(gs4VBjV`@to9_6+#B+?QU^8CtpK^BO< zCS)7Om^kDyRVYH0tFKao1G*=7p5xOE5iu4sK9g0ImNzcjUfv<@P{Td;#jUAJ7*_{D z9$sd|&`rQAmo}q*+`&Tyypth<+{2Uy?gyoH0D$;SaLz-~^4&nGNL-w#d5$gZE!y)t zJA&TaODRo1nQSiyCMo5N=|r}uyLRHG^cWlSaV`z>0Di&qhAh1>oE<~w6M|STDmftH z9L!B6mcMa3j2Ee$!sVIs?rW6uDy;m?0{MvVdylAmF?dI~Z&iYRu@+~#%|Kr9gHhEO z8h|b;X&7b(-ze4Bj<8M&Pm;{JqST}iZwTBWG?BruIart_`>Inn3`=skl%i?|AWFKa zoe^Rw*Jo3++JP67w9y@N_)_F6klPiwic-DEbB6%sItfx7*uR^Im$I4WUab*4joi^H z*NC~eJM%EOG*O2Rj&VDxSz`P3bm9((T&DfNwPMFicw$AhG;}MWAvs=71-zVH+o!*bAI9b30!W zfyk5~PF}pS90Y|&%@N-S+b~AatK4yuqfjF!wqLKr2qmMSoF-{ux?hB@q|+2cl)Z#% zP*iC|9TiNbwGA)}c}Ph|+}arEjD${DGsruhnQlPaD&I3wy$M}ef+Y&-Se7)ez(j*L zP`uufv7^TLoz=KC7YWsc0@0yTq)c*V5-!QssfYgnSn}Jqu`HoQB=Iu{fSzN`v#}~i z4F3Q`+2%Znf>#dZjxjt(a|m$49IjEjCfuGNC{HeegXoro zYsVc!tBqi;$%q0p#^?^E?7fu3#ji}algSbEeVr5_Ykbc^Biqq&w&^N)9w^|dD$*h_@C2=EDh?M z5}etEct%nWJX48m1%10_R1yj(Vg9B`Mdd81{G*aNBAFBUhcCHX86s10-Y>$1uDD8p zcxBf75~7vYP^*0MJj%IO@ifD5+Qs;kuKpoOq-*vP)D6Qx6_yl758Sf?GzV+q9&CpuU3iynuu5M6GnVdAMYcGB zfmZ|E1DF%W9cF&0&zMS^>zxmOLidhVGBij)?G- ziU-UOmqItlGTtVPQWowpN;^jAhSYgM8>B5xRXnBIH#~?$wm zG)e9y0Z$|}l{=3xd#SXJ;6OoUb%;{WI&NhJ#Sz)Y;V8N_z^&Y)F4_$hj%UK z9n5Ch)#6`qb0|gQXSvQ!11dv^uYO~AEx2_|y|J)d@d$rN8ty-34gm5>6(b6;zY)2| zfWwR;#MtnaMZVT$SOi#u*_e=^trsh>g1zOrc&o}TS_SDGhxVI~v#NsYPBz%0O*=nWA91)=!qj7@WAHVpV;UxL2OSL-;CFIO{2pvXm zRy-IMDA!jNls9o%K*U^w;?j;m-H}~m6xQR?oV@eHHPy+;J<6JLi|F(qf}V47%xVoM zVG}8S-9Q_hr6cVP7gozDZ-SqJOvj2?%}NsENhx`J<{@f5Ct(qm;B+j*49yS|k>Vf_ z`$j5*Tr(^di0^@|rwqS6M@BrKXl6z7ve{k|_jkC>JdkTA7Dv}SHb*zqnB++tx`D&W*d8T}!7H_x&frcN(R9Ma-q2s`rxJ;T($ zT5d(FwqrF0AiH6?rYB$(Q3kFI^@zh>jxG)jTU@NC`AiDw2Yz#1O>%N@#Cl)hl*~1z zl7QEkzb_4Nnbj+L+89~%5HF}MAyUPy)4Eg;q`7ld)Sv3x{pT|G`1{tZSFwSB{PBk`ol~>epxpe*|TD}ULEA9qd zFmI$S(??Rjk7EmZ@^Lg4a?M$44uV)h^3-k##8Sezq(xlwKEWq4-RNScQv#0a7o#1^ zMl^j)Y$h!(RHz<<9SA`helkR?dTCLjM;^^FcZz{sPp~40J+7rW9}J`Ouj>{*p}fIn zyO!CFamzXn94fXlv^xivhNYG2<9^lehj&C;zhHex&CMI z7NbV{fSVz*^PB$w#Fq?(0m0BlVA@A9e10+1A+N^}Y=l1|%uBf0HxlRjp_%VdC2~wL zqh-`^;l87Q!7{D2)y-jnTrLus>>V83v{*5%vsYX)??$hgxZK3gdYAVGo~C-0CpNfZ z^;`23TDQeS5^-r03jW~$Yz}{j>x-RF)N7?N)7|xdYm>9Fnsd5@_1^P_Go}ybS2-DmHVz@!ge3IHxW}p|haJ-|- z7y^yCs5NWlh|(Rh%o}TNY8Nc6lzGMsV6>>saLzk;i^a+uOBoxLAg%uZx5x7q_F6K| z`6f5aD5c?!Cm)V|{!V57H#r>769$>4rk@g_mBSjAGhU{%1fk4Ivt1J0Fs>lym&u7? zp8AYBE10N?K&ygGisP0D22fY`8kmP4`KiIe)*>qQvz8)n@Ly9+D1)i&)W4i*XKlo| zJ44BZ6)eykP`P{#bn!5^z`Pj1X)JArTfiD)3oAWBRfDk>VC>u34O*u9Gee)TD6S+5 z?H6`S2VocK36TbY`GS~(fAgctl|=?Cs3_vGaRnrmWbNh(ab93z%P`!mha{^Z6LWY|(j z6LP^$NtCHs{0+kzm5U}ATsxSoz^v?)d_xC`fVoyQ;^SbCWXQMN49$6-qAQuk`P|27 z8$zZBQue%Rr3#UcX=qY?l@t~0*gGRe_f{vIRNu_D6*p@w#Cxci9w4e`873HsMd`!P zx8gqjQA7?oh!&?(kbEWzvu>04nWY)LLFgSj5rV4uDmg3K;L{6SV+JZ55vyAFlLeI1 za)$Kx9&p!nac6*`y-Ks8rHzQV^SCa|6U*kKlBhd?`La`bDGEkLTd2W+XKd~GKdGN| zOuL!tGkAAB{sy1L$Hc=jY6IpanEYaTVOYS*UvVl~0gpT(@h1XAPq*P^@e3+I+vNkQhrfphvO<<4J7_&9eO~CToa5P_0ZmX3b{Y6>RXqfvEGDX7b{bdQ9WhTg5l}s|6G-Mp`QTi~gbg(QCB{dR<3-TpS&oTK zV9Fgw@^k+Hc#qxtouAL+GjL-VJTug`T~AZu72KoFV2`kI8tGkSsZFqMybo;on+;s^27_zlOIi(R9C@6ra)ErPQL zW`3Y*8_|hXobaYtDwtTzvDQ;7f~bab3^PkS!~pDFM$F=y7ze(84hejkhje?)WM#)t za}q-jJBHKJb19XLJtZQcrQ9Eu)=6d^hN%_DD&s+3hTy>9EFoG&tOE)zC z53K(HR6&EXVMJM(XxV}5#7(!832f(k%vdhVMxjg4@^v`l1f!_gK(_`zJ#}SNOsTyu zY-NGj%a);^0#U5so$iVHg~V%|4zI+r?O6!AhY#Ef=C$G`rP#7S((Sg)QH~jiN3)GJ z6SJ0AF)7cSC4dldjLY|bUCW5F6?`2EypM&Ql<22|8@2fxm= z{%`SfUZq)xHHK6dK9Z6kBXaL>Y?{OQ_R8u~ew}&WTZB}}9oVdlJ7fbILR2c4R<1wi632V9NC(jG<`rs}4IjC4Z8+qPHFx6x zc~!M6NLs6W8B}}~Gx0i}YcI?b;DE#=5{eBtmZ&vE{{TWCW19>25t_zI+;sj_cK~$7 z;KkLcjvnFV?~2Xdh8G{HikyJIL|{zY{{RTvS-ea@X4K~8(t*>NccV)+1F?3(Yb
S(=qF&`Xy5rJnLv4mTY6Y)RtX%-4^GKEjSyiIB5Hm3WrKvy?KZ7Zl3mxRt`@p$=k2Lv+RUGyOfIu(M*` ze9T}4V4L@DpYeQ5S_KU)MDKIl;yhdI57XJ=P#KJ}PG64@3)brc>LG=Fk^7SW081zS z%v*lya7(~{rY^l6lEf4!%pV#V?JS+#zN%hgYtuD6?78Ux&FaM&8;>609Pt+uKxgxWR!Iq zKVf3{?Wla3#C_foSHoDAa2uoe6O$Qr`AiBkZ3ZXAI$BJ-a=gF9@u3*{^FQrlcP?B! zW`A?QfvfP;J$@ErYVfgI2*b?OM-;zN&32#0*X}aNt0#Y`pd)oYpd_}-18V48MrxIV ze!KXZU(5dhsusePYXQtUWB%;LZSLx_m_VW35}*oBZZAr&Wn3Twm1U5t!4p6% zX6u;akV$>0eM1<=H4waAx2R?2HXA##5wxNEodwhVbqx7X{{T5KZmybt0b40JIiC=v z(Z=J>^PVstvD@=CCt~GCS(0O~F}Hux5QMM?E>4+vFbA=Gx~hbmUS-leE7G-rhx>Of z5S!5=Yc@cYr0TQr3=@3PHLEKuRmrv*%VrFuSs6X-9j`WjKSw9H2g9 z7=+!;GLdnj{KB~VB0lw*@FGOkui%)fASM})zMw+xx8cOs8A0gC#_Yv=aSs}a{zNo# z$~FT0OL!lgL0(kogQkvJL0SUyKY%God0nhSqNV6x=1?f&0WT%NMS%gjm6ZCGJ8l|@ z$yW;i$~!Jwr+IKI*)U(L#1kzp+5TefofIe~T56An%s>I4pYtyaGGFl=Ru0`oUMN@j zK%CiZaRrsrbvSz1@e=vNJmMscD6gq&ZOo-dq-!7w@8Ski(X%V~wc-Y^tayh@k6Q~qVbt$dJprfJ-`+uk=HBAJoi;41v)UuW74 z#5;|vE?Bg97>nudF74NcBveM=$0j1{LH*0^mz$cb9sl&3cU*k%!! zqgRGv0*r~NZRZf9TYUt;y>%Sfisox?2h6UkiqV9<(hwlUQKB77@vbw$DCqKvPhFJx znCuRmpB^IL>WD--hQuLm;M`IuXDrQlSc3Q9n@l#5RjYGOzD9Oz(*W@|29;n3Gfn*% zix$OM8Hk?hbMdJKHvGzfBYBu{c`9X?7j5S-9+}8nxCW1m(;RA@M&D@LF?3%G28{1f zMf<3{qN1qf`iAYvhEn=8$^(+BDME61j?Fczj@d{k``oxV|sT zK^wK}Ea$9RGf-c%2pV$lQ08d$1b-ibjn^|v7fZt*aJUsf39geT+(CUp%&XE_E>a8+ zB$f3lSL8H{d8u_B%n4pt=E|8~`^sKR8$EY3oR^tkI}=jCvsr(_GzB!#mNu&IqGv2P z7Tt9>HRYto)-bk*&Y*A?I3F?L+iE4U*;Dr~kq0=$;(ZtV#Uw>Gb2A-EB&LkvnO3TF zP6Z-~N;XD(IU|x#mdrT{m$jq5tGH#wbK)-9b;Qc-d0;FDwp!hH!91_IwWfzv0;*N3 z?g7`w5i6znkAQiWCx=lXd$w*`aFVA3ZUUp9s5AVKxnB69R=9cM5{hP*PjNeL+va3s zZJkdVxiuNAd8}rQTr$qtF)zn2*TPp7jq~>{PVWN7Do5dMjJr5QEd0ykqUqGLXr5Yw z*>b8&B`0LdGby1(<(x(H8+0*;8kg8pW&Z$Bf+*^IE}-?4Gr@)RJf;lyfT!wl%1BJW4ySnZmGmlDMK=j_X9Hi% zQ9>BLQ7W~)7Y<+BC%HzCvh`F%2eOaxa}6;MFvzHEg=RgxRtqch(-l4Awa|)Abmi? zl_QoKybuo(X32SN4Pgf1n!%76d>OEBgelK?V!^q>#_nQSB6g+C#VdG%xy&^ixS5Ls zsKl@LkaGmuyJ~a=Lsrl3^pOS0DKE4id zFW{S>Uq4e&{LMldpI-t6If-hVvlER&O!qba0E*qhWr4eAA3RFTzlxO$oKz;?gFA%b zfeBEYLRI{8iO0dG;D^D_!R8-x*TIp8#}kL+bBKHV!WkGtc%4FeoKG-!@?uo=@!U7? zVrSsQ{$MTK3Y8AN2p&Ev5TCi+Aq`FQ3B=YIb2NNz2Q!((^C}vj@ih++l+@e~=KaG^ zo<4UfR&xzv4U^QVQ^eE`p*_Q29sC}le;;s}{{ZYUG)w!HUnC!s;M5^Fhlx)@K)_dQFc zxF48jxyGiP!{Yn+<19Anaq?-ef^TuuHJCykBQM~`#+hFRzH1Vvarrea7@K|ynuH0f zCbcWCp6-6-+~enujy@0E?r{ebaP#wvnqkomLv!9{ehk7J&%xC7FA|~W;FpQ}`5%vS zxNCnE^Y;jIIR6003^NDdgyL!+_9qkb1{j06`-gu7J^Xrxz6{+$1nyip-^UQvetpkV z&;AYkcMFHb%lP#V8G|kjZOkt)gdk#F+#&INOS`B|4^YeSW8>}|-X%j4@dqBE2S4~X zKH;hRmyd&=j{Y+^VFNG8%q7eDI{4yx_#r#azCF(84a=*9_dA{3$H}XOQ-23P9o#Xg z&&KK;&HNTZe-NCo<`Cu;{{VyH{&f$FoJyQeP_8(cr{J7o5UkHoH#nE~JXHO|$HmNN z)_|$W&WnQzM%*(b1r?&&*%RDf4F#u z>Jx@;A1AqZgyLK|`Qt1;IESb+#O@mI9$_$iA05HoJ_vkfTqh8Q=AnuE_~C@T!JJMZ z@ysF5&Ew#gDroM0;kj`5?sxKGnU@FRrTU!19~a^ee+R+T9M{F-68V=l%+oJ(FxSWTE_;MJnTEVVVh-UisB=?^ z;#--|#qa+B7xz1#_I`VWIk-%hGrxh~$owDq?hQ>#-^X&{JkL|ytGy$OE?3TTN;7m*1PvWdh7h^e#yv?I;j-^tI;zCUxhc$`DTGdO|MbBKAHo&0yl$*=qvpMM5!ck$dY zz5MDP{!V8Q#vz}BGmHNK#}iXWQ&5M&IQTjExx?|J6GRJF&@iSVngONhS$+75Kf=gI z&n(6VAJGWbewBgo2;S<{cFRJwe~uz>HDg3V?$FF_YpxE*ybbLH7afL~#b^F7RFY{WD1a=bvPaG&47&r^?| z8;->Lx*WGe%a-v(@O*pt;t!vSxlaB$hlq0x^$g4TJ~;Wmf*SAOd(74RbMSoO zhCT_z_caMsILtgu!fI4Aqu}^Hc&Fpn!KNCN&d21yev5rZ$42inJ1QJNMNziu7Jv%y z*WXNAa=yRRF<4!w4IRrXhFFIMmSo2lEGXh#RaUYFDZ#EA;udMt74ly>hqTiwui<|2 z7$}GgENnHa`eAJW-))$_$zD1Fk(5&T6CY8-t?33d$~f5UvH>B)1*XnCEzz?iye9tu z^fJEX&oP{4UsF)#5YPS%O%rhD6TkeN!{F2>e+S8##Qj5G9w!j%;GEBJ{6kZZlMt0~ z#-TWYIG(3}Co$CF4CNJnEXhVLHILEYhAJiz{{UaSMRuc;e#RA4E?VFBFjcK-45^&I zh&&O(1=NeH`|-$3krJs`u1FI_0hj?+YS@;Tc}!>h5E&W=Rk|{@PO4Rwc)1JK95!&> zM}W77MuS@gbH1}Lo$8wmEaC6}0Fg{VxKtL7Oe6fqKax0Xr;DpRHHdSx3ztd2^Il*l zN~T^r_%RIpYIiSi{{YcA{{Y{A1RsrjT+uy4DvCfCoNR>h= zj|Ey>ZJ?`l94gMpSRW`h+#8EL@c~3zK!MGo@#0#tfK)@=rmyi4QDZ<=u(UPD;v<#9 z{5E!-KBhvKLnT?@<4<+%PWNnFnembi8^av{7^(2Ky1R>BNZ*GKzxOvggVesE%-e}` zUnf5|JNUVH`Ql%4{tuh8r!7p(yKx{nzCK~82h{e_+?zaB`Bn1G$uOB|C z`%G4 z_=z8g=77TLR2iY_Cer?Zlp7I=j4t2d5LyvsUj0EEL!`B~glnK(u<6s7hs>N2ssNs4 zKX}A@rpmVt0X#)`-%~vNBG9(Fn(^{Y{13pL!%=6%M%1rz$C+O09SA!vdOwH)s60)n zS9Jwc$?9U^^#!_!Jg%_@&4;MEpf<(IgTZl1_mC8m40Ny5FY8wW}>-36bDqPs#_cJI{lNpJtjvV|XkPMB-cu^bm+CM&1xvE1_2yM4xCw=dyy9M% zR9O((;#`A9#$#zYB9Wsaf*^{@;3eP=gl%9YxPaN5oR9<$a#;3*5aG8OGHr$}ILDbw zY{nnr3K?v7_=?&MMa<{8%?xmno)`;-3tKS?rXA)1IQ4Uwm2yCBRWopE;q?UK3XA%VfS?Rl0f`OdN@CElxHSwE!~rpE9fEbly8^s2r~t-NmeaQ7 za4T>sAlXwN3|UyfuA5nGVqvA9x?mGd3ATSkTqTs=d>=>R1cQtiy8R#2UV~A=@qgwZ zOPIpX;|v;{XHlyTD-YBh*s#1N2dbu7Y({}c+K=Cx8>hc;#WSoo2(;RSt|pcifb;(V zb1+-clTap|({1z2QOHnX3n^4-yjGrNI#ep8L>E3>n8jmI7YZP}0OituIGR8~IBfn) z`_A_&O5v+jxv#(G67V~gbHut;h?6P6#%7MVGLEp>DyS8xgKv~I9yDtaj*kW~P8^Ad zg=uUHiI6>_2~b?zNE1x8Hei^KZE0K*P#C%xzjBCdgHj(EW*#SoQDxHTT3do5Tu24> z>Rd5#7$KO^cPvLv29n(6pn#IFw*=T#ghn#yP8jXQ%Rkz95rIGn$s<~)<9@IT^Q*-ar=rdP^gU?0!(H6R5Qw<k9V4jMf^8nP)Xm)WHO{s0Ni@a(KT{d!o5sv-!`srvYek^q-NH z9W=-k3%0s~q)$tZ%2-8`SF49G(Av$a;g*|`YwBNw8KwmeqYR%nNaNH2b8@t6k{}hf zh_zPg@Kj8H#hneXWtv{CG5kgY>X$K^2h3Vr{X@aqRUb7V4TKn=T$hq<`r#Q>Qt5XB z1S2#A9IbP(c2N##!FH2jbQTlfu@Q70K^KN%$!~`4Pf9J!OR6B zypIa(=DUv2b`6fJnU~;LpgjHCZ!t7xi*0CqT)eHqu8$89=w3y@V)OtPNoji6<<7YD zU+EAD)h0{}{vnd3XxKZ5w`;aWEs6y;$9Liqd&dLTJO2Q2fVPVOTE;zYAzZ2uHAj-K zH${@ZqAhoz{)&aiD@|UM$I^B&4P1z956BVn8YYJ4^ zFeGX66C^Gf;K>oL#=u5#hFGLP=8VhWQqqUWyLNn{Nkc2#w9=}?ygYK;b-ZrEBnDdU z!!fE_-8lWlmIB}-Dx;J-B59>06pL?!8BXpfflD1l2oM$liZ`|rc+M$INnwdDF?iAz zkC@7~K+NYMH4Uw-fgtySts;OgMx(gu?^4};vmvCbNRDHySQ_p@TyW8Fs?v#UVnWT7 zWjI98>K{P!66&LxxH&*^sGNklK&Yo1 zC+U?6i_lYVsgac3^6=>gh5SI!Em41G{{Si-APAy>gf(03qT?7kbEs&L7RLNAZKE|^ zJ-~vaTCb0-MWBsgO}_5D#}dGUAh7j!^uVAmi}dkUdnuUcKq|wIrHpM*wGE-n3bU8V z0=WibNp$61GLxlzg@1Y`XVjMZVjFVK-NtSy2>_ zWvD3x*IM-o)<9J!dU-OR{EH}vLauTd*pHYEWXsj{=l#ZrYuXhSSCRalB~CmfEHM7a zY@Hm-h&t{)cYq|v@%u%>TY@IsFeowJmh*QJ622maYos8|>Y*z@o5v)vQG;3NI`28_a2$AH6o36bUxJ#1Cf z+RADfxlFB@d?MAt9Y*zZq`7n}QUb&(I0f8YZmgzi6c;M$We^j6ZIoafU}1PEfto;l zs>r}|C2W^yK;Vf4rC?NDMQBIvFFNBfk1!Rh1?Aa_08>e+YM>NW3L-`(SaxqKPJ$My8?+A7t7!c+OEn(ZTP=P#%Ehz`L45wpYn zesk~wwMDw|WkK|!mr0W{U@z3Pu<23^R*F!7B(syETDTbvGOr(!=Cfm=Gm&|uY86It z5SyH7l{P%iqbp`O3svZogSBI9vSssBTX?P15TR4b<*y*6Rt z^Epu&{A?hWFsdlDt54J6ysy@cuE z;b4)_8G|St6>oWpSVhi*GpdufiGl#vLO3)jRSI2-a%e7Pz#vLAEO)Ae(sJU9x6DiA ztzIrK5yHBhdWz8K%FsF8{{Xmh*HvU8mE%=pR#nxNvzp7I9dgJZd>_|TC`NEW)+b&yUH+q!8jG5dw9F3ACg_&mFlm!vB;@ei-j$n7!p@jJC|_GwyTA}8?xo;N>v@jsu0a)t5}8Q zLOK`&Hjns>lny1+>iwS(f=bSm!Ak5@mmZa~ly@D4$zJm?jY3J;xL6o-8$jS+G20kI zDg{!_5L(dG3YS%5&CSgPm4Ap=AlSTrxSM7LvR|)KfP&H_HC1^aapnT(1qmz9`hPGa zz~MF_(bg$~1WfSm1g#Gtcwt2ijxcwQqgx%^>r(Dfq@$#A{lqSSVHsXv3MWvl^!>*& zh60-o_=L=+gCMN_c;+iuT;Zc$Gx>lL%Gls9%^%E3wX+;oXQ;xmn^;(f^C<9fD+JV; zrNb$hT$q1ysf4=m?qix3!a53fGQb{@6~G1s1Wjm2CbZBjWi~x<#HD_e>6UFnii&rF zheM7qOIT|b@NS7Qjm3-gL8r_}Vrg9sH<7Lpoz^!4Rys<RFO){vc+nfNafg~>S6EtSPs zbu%oHWTZnuAUi_|HGa2m!x`pd-) zIy4OHzo>vMI2vR;q5RAmz35$8b$wJiE^w|W{>XSiX>ai;Ti9QSz0!4z{gnW~T+YOl zk|tfQEDzmr;F~wGisO3O5{EpZk|gaNrCW01KqI0S26*X%;PSHNOmk_OTIdu44PJ>i zEd5$S#NrZURJXgB2(<>agE6K=UP#LjVQGOXsF_ps8MP9Tnvcj+9FqiTqLx+HRszeX zQH>ghuBAB=l*s`WfK^r_x|q`+)-lNQ9TK@KL@XO`qD(9{Sfj*yOlvI5D1!qO9umk; zFdxBzn~arVoco7}JQT`NUN{Ix4qc?X#@;pD94S6lY!13L8QxX3pYs74<{Qy7rppma zS9`cT3}SCJnV33wmNul!BVc31$3-CyN~GB?aX22KoE!!?hP94v>IpYa4sZRy8VN(+ z*Yzlx9^2p4NJ(tt3=j~HK?ziqX*1x&ZwD)v@)H=0&K!mNC=Esb85$)ff8vRRX5^ZrUmv#!maOe-Fd7DA0!#C z5r91#>Z}6S?hIH|Oqb>d8ge>jN~TzHOwiWq9gyrpSy*8qTCCF;49mst5KQsS#8%l> zc8gLDOd=Kb$Vk{-0+{{J(QFpO_)W@HlW(=`t403&Q^ZP*Vp(7E{Af zmyw|zz?j3PYOOmvm)gl!<|%UxJ;bS(Wmf#dAYb+3D>>abJL3?1tzHmD zuFJD(Uz}w)_D(z!38HX}Z5TzSH!2lVb=mF^H(ZOCxGv3#^uiWa4hN}AW15v zw%yd&QPS~7CKZbk>eSp3oDFrN{{X~dRv@?@d~*WOk!iL)P!Fh#Xe?_NOlI|CT$e1O zm^7wzm6Mu$)J%my*Xl3gwSoGVxUmMulZoFp`G9po6A)E)X^ZM1U3nUm)f@{`G@9Oc zf>*C@D;~^BOvkFVkajDW8-@Uytf>%^O|Aa`@^2OaV0vb-K!BoUp;GMgn82`zIq8B) z84Q&I311;XTJ}Z9L_uvRgisIFs^uwks#X=-F1V>$eZ~ESG6yBfjg-ScSET&1_0HCa z9&R)pp|{5|qL(W_P=zXNttu~Pbp9S==w+kys8xai8huoI$w@+z>Cw1R?$oddiYUF> zuiN-zNm$Lz_o=v{ZQpaajaK`w?l8zU4w>;QYVJljZd#nL3^O`|Az8TZFiNr|w+|!~ zS1#fZTsXd>i&|X+nX^+AS0uf)#-gC-rWXRn)p&#=n4?!LMKol@*V_0J3&3;!QXpHp z!dH|LOAC2Z;BRuvLdivshp2)fB^=bXExLM6pj1kb>*}?+CQn=Q? zmOFL}VF{a5tApdzD=r{${{U`MFjX9q)W2`kG?Y5pR=%!vLR_-^LQthZrWLAemY|Fk zHu{UsxWk9sV7|#du^0aUa1;i!E?>-mW%sGFZl%uMzzODES}XSQmJU&3-d|#Y^4vYy zm0yKKiI~k^69!96QJg5MocV=CL%l3wTz8z4?pH9ugbt@AHGDtZxdaLhyJehkpwlkJ z3Rc(wnIals8^?C{4hXk(6UlfK*5%JLbaMShj|~md#M!JjMl@eyI7B7Y1_;b}qchXQ z3^lf^?h@I7R{{^#3M_**R(#M{t;NGpOPVlu9}H{Me2(Z${MgT{9KT%zG8ppz0AUG8 z*DrFQ>B0TO%?-!C=7GFnulAS@+m~fD%F*f~iOfoVWo5uJs>IkaEr;qgppmIRF^vi0+jOt}Tz(}QpHm(jdVnD( zSe4|kd4aYLF&yPO2h-vLxVHZQk|6=qV&hk%W{M^Pz*C>12J7cDGRa5;REK_J_aduD z@c`W()?G1KYy&FdR0|kT4ax(OoCi8<`TfFqVVM*( z>VYt{W{|+P$9x@bYp6Qx^6SC{B%?k?X8F}e*RmkW26>)%h|9XQN5|Kg*y_+HwMqqA zaHsf&gJ4(>HFAv>mM!BhA<%9?wb=-r08%BqMTlZ)O#!#!Vky#D5CH=U*w&VLiEu-_ zmpH^^OMT`8x|`rC_P(o@X>9hhYVn8{($ib{@#a>1GX&O_rw*mfO02L(kT<{=_>Gf~ zSj=1ss)6k)Sgc)T_*59Y*b(su7RD11do-&3!z)HPLzzIOv^=Z03_6!V-00)XqZY=A zY5IaSOC2fW&*=9qKsv_v3@dlGQcI~|{Nnu`P3tTgF0TwMMy6Z1V18vf0|q+WsFk5R)IShma|kwJNh!wrbM z@|(v6dxn-(X?r5IAPNm)H5pXlob&pcN(*dJUCZH83&BfkonNTBx6=qN-Saz=NK}fz zM7f`+MpSLH>k$(?flKYU*hsJ$;X8-6dr%bf69CD-*NEX90ZDhdjRMjS+TmD(YquHe+AJz+rgFXYNu_ zK-xQauMlBCWH5>VxCpg3>6DnFn}lQcP)ZO!Lw~>GCY)BUhqvMqRZ^*77Ons?_?Vs8 zx74(egYx6``jonomqTk)QG1~g2b;O>20RM7b(SD$nr@w}mBZTu5DE*v^ZdjyHr?{r ztdYN(NSD`f6Y$!PwU5BAX%EV0{w_n#UBp{)VzJ%Fc00c-AqHk*jJGeU}l!H>Lt66>kUQlpkTj5K&b&*_vIo30<@X)h#tH( zc_1uyQzNLqFe)hdS%`*!`R6i#%HXuqvfw!#3{pYAr}P{eIF4eolwe94cxf18 zu-ll~+2g1M5uwt=?qAdi+zd`&uv7m44A*7lv|L%HkKCx7hL}F}6h?3c{{RrW45u_K zc}c;Kp@vi?>H~Rmk5eh>Y`d#Z@b?J30c~nkcOO$bU#MENt{*QkvkEcrcss?x12xfc zTan&)SlPJ|u~($k5xW7c)TU0aF>3)t5&Xg-f~HT3(+_fo7AfX7b=zT9{6pgdRd#R3 zsDG0DC^Km)0xd-9JVsAUj?Q43O;0Q1vL@wYQFTV?q>2#W70KWA1CuLPEg#)fT4cf( z3L378o(XU$Yy#D~_QuJ_D!8nU-ldX+F8x?n-!b|h1UF(<;j~8;-0>6*EoH3%r^I#V zwk16EA5AH`qt9}$qd-Es^&L=*fFCs6ZtRWd`k<%{z$v_*=QJL9{Kb{dn2GJ%DML&m zmMD8No3FUO(zP47U==*T<`lD8YVE&wG775|cO$fYW#R*eppP9OWB1G@nHgN2yqcHF ztn7#evO%kRFu{1{1+%7AVay3v4l7Y8b6mvHvoLHcn9PWHjZwo)!>q*4a%qUwXTNhp zazA7EjM9NupW;%l>LV|?D4BTtLf4W}XtR=ClPx=in7nhUiB=ZMm}07oH@diL2(yRt zHOr^^j!n8##J0AjD539fsgg)Fzv5e<%7`mmimh>V5~83M_^+q9@~IJZOmi3mg5I$$ zB4jJKQsBlY_2`+aFX{%0^Uh&4;SbOAAM8aZmBC2(st$Ok8JHM+uu;~s$JUJ$rRm0D z76n=lhPCw$GZY$>oKT=72tXn1J^uhvc_F?f?zm%IZG}10E`E@e9X}+x8Zxu?PKRmQ z7lXvR2QAUiZDXG)URBxRxGD^$x?6V&J^rP9GMlwDImUcIh7Xw8ha7&TL9mR3pd?_& z-D(;zEjcf^Lnu8?GUs=B_yE`XVuPot;W>-sT`I$vE*A?BWlC2awSK zXfZkfYl(F=mI~%$>Os(sgbePtq z&M4w%E^*J?yq6dX8#2J{eZ>N#V$)dlP}xl-9%bz0yu=UO%{dqOmw{a86b8RistGhS zbDcnmhB6SsJZo?jW{d79Yo(kyXyzTsTbP6@hi`D1n;bB~w?g>w4h&dIxZC-|=3b(U zl5o+L;e=2@Y~+6RP*D4d#Wjy}fh=VBMPvZBj#yqn&F{<;wPra0h5hI81t9TSL@VSi zOXPz<5muH4<}0|`6CGV? zO`dv{FnI-Kl=;ya7F1`S^o+LKh3e*+V}>^~x4vM@P!7pl1IGLEjQWq^f;rW} zP0TPflIQk(@3XPKZ-JcK9XTEz!PAV0g9jIp;l~ z-L_t83D66I_hEl~frzo3M{dbv6riE$ZC;MB)IH z2XT^DC7iou!cof>a#z79;hv;Q^u%t)9NW_j+#je`9Fbz}P+cwkKn>);jHvY*GMmf* zxN0z9q`8urhV+8x@d3@#_>0YCHC5pl*g9rK23%S%m|qrf{^e%C>r6qjqM^GE3!K0X z=5kl&avx}Aj2;ZZnAmfvak9A%VR1lL5{xer(D=%Q_@X{BlwZru>KA4K&tP?DByH3{ z>xpD>%olZ!{>zRP!rp5Z)XE9^fqUgfviGS&ebE|h6;C$G3N#5yYU{jeW35Gq^DnE3 zvrqsV=CDJw8uVcnv(>&2Ag!w+n62 zlh5b4sF_Daw~S6Q>_hm9v-AbS{{URFW03^YTjbP8bDFBeYgUa=%PE8YQM%LK z;7cf@3f_oQ_bn`GtaAb)yjL{dIF>mM$w`2~bLQU>C~NCVUO_9)<%p3)y&5*3#Cb-r zmF`&DDDE50xOAJ9L0CXN3&R))HHFx&vx~%X1I<7dov7VtnOE~bt|OKhn&693QfyRb zs7QA0>MXY!sZ-Jy#J%CTOH&G&`AT+SC8lv)N;x5Yu`gFmeq|yY_bq(Iy^y_vXOvGc zXuiC_IouIAscCKbf2m{;oiX?_nNBwVd5E_PpGcVSClB>IRpwk;UDOoW%mHk{p^Hvi z%re~_z%T8@C{?5TiZ)iA1MVeZ4vL?dVo(*6`Yfm^RX~8N*C!!NuEUk=X~6K5o)R3R zKo?DW<56amYX>d#rybc7P_C7Ot{>tw)Q!6$gfytC>%UAOkOkcSe^K!q7PVG^R`+mJ zt>}hK<6RNONCMovWvSXXV&6~kDdp0S3HG_7P7RoDuEIGWrD7+Z{6n3k8x#lrpgzGvjpyMqZMxv;} zqZNUlN9F-|gt9tbf84#&0Q45I^|?x_7iXPvOc^jVCr11A6&ZyqXVpqeDlWH3itFwq zG`HwOYaDU-h#@S%hIoH(>H)P4hLiJ%05~Z&cHCKAb%M=YdJvmd;6plVd}nh0<6tx0 zt^G!{wWBvu`O8<}h=*=`L2FQoL3Egf+98dNw+)ob@8!zzh^RA#}`2?u50hESy*U{_6*sH^OPY`XOT?Tw{)#Gov!Oy!6z zE@1&zh>_)5Kg^=a?LmR+Gnf>vSH<@W3N>LK5A_lsXfo6r9}lUTU)nvW7yT0kQBxP_ zN(y2R_-OuNcq=)v1Ib8_zM_}|d(kuIY=*p}rUG|{@#6WJ5gY{Bd$Ie2Re?-x)i33S zfMuZOzgGm(o_hYG$Eq=Ij(xG~wJws+V~E?qWFQcwW`9!TKuxUDwm>4Xs)XGI;k_n2 zjNb*fA>bKip2oijq6*HDCOxVlaFpvk+mK?gT}vXf1;aKtt*PpkE-Y!F-MQW{lM;Y2 zL4u4UyX@R+I+j7|`DKZorPEN~H2RGT=EbwLAP&P|TRyCUT!gUNyc@uY2oOP~4;Ht$ zZUv*7Y+iGX+@on&R_VN7(XUW`ECoT`{{V?tm%JlcTPrsx(6sK=P&AdUmNVsM1$u9q z@fk3IyJCJ=G7G%Xx`%6^KYUgCxoPU74~^}(Tz$%n_<~w@Rj_9LOM6uZDX_z5?#?5L zlEtvLqbpxV<5ihp(RP4?!kOY)AYo@d*goP>fMUm1s(47q(P4$vj6SmXjpz48)i#33 zL1Ui~29RYDoJ{K!%2NYnHPa3DLkKjSx3p7;*salLLoIy}x0chg@aMj`m~v4BDJE&F zts>1-Qm)u$ILKav4{i2y^>`kjk}O@<#2lv)5L;`d-_^#eM){S^{up(Xz)}8wv0qiWdT>xR>AK>%fbiz*7##*m zZ?K4OoF*MupuTdxCN@O^sMeN+m_YGny2l(MC<;YXyf{w1iD zas?Kw4QM%PxM>wgbZch4!UkXj#5GaPJ|Qggo zqT0K0%qE=3q4-*rJ<)&0jF~M5?pFG+1h2(QP!!mrY9A+PNM1ZvJKSN-3gw=A{{V8T zKrFS{#=DQ<;IrDfg<1ma9Qlc8hA71r)^bW?<}Ijgx;CM)90#mh#j9`^C7)oy3aO|; z{4;1m=x4)FMp$#;Il}{;<}ZgBC~KfDQ9sJ~oMdt5zG5>gY=*~{a$ccwgxn668~t|^ zsWvPcDz7sZO(yzETfpvH%gKeJ)|S;~@Vl6U3Mv7+41G5@_gp~RSHaH_4s&jG<1o`& z(uP0930P7J2R%uK0_*wukcwyRmJ9IZ^)r zGXD6NWB0fz9e^yqsQhbGA?Pymh!n=*>~C-C4I=9-K9ayPZo7*g4%>e)aA?DcT?Ium z^eSMcXbVxSbp#eY9Fmek^6BOAQDKWyjNwLBvO{Lop+mC2iqA3luvjCeo*1I+*HGY> zvNtr*!S-DqVaQZkeGs>xJ<5}0sWSktCfC$e1F@hb*BF9T@EaIZy{g-{P?M-(83#;w zOt7&-IxfVf$e9dP3jjG4TD4rjsa}a%d3*C22!w`R?pzPhi|= zR1jNzOCXb5*Zssz6gVGu`E$%F+Zy@%B5{Mi(nM7AbT)fhN z(HKo!jpEoew|4&k@?wk6q5Y{A4mmBp)B^QzHjOVehCx0$LaftMe#tR=rsq0 zKl%|6AfX1e8cL-Bu?h&b)otV7s9c1mjQDcPS@V$dRL1x#0tfLcU{&f@RM|xp0MfIv zXo07q#CXRMiOI?1*Qw3`2>Ls?6@Rl2uMnd;~EQ<{~< z6!!Z>#=sm7@u`yaQ2zkgujXO%02Z%AJd3%3^gt*IsJRrks$qJSHqma(+)eP-xrrw@ z#cd2MO6^2=ux(oAbjMI%Pt-0KWpw?@C0ZF@R6&}V5LUk2MJkh3aB)#i6w@;f4FmuL zJ4z?5K#_x&?6SlfL^eTN$8gcWD$89)uyALOxMdG!tRZ}b!D>U<1}#P?8b08u3h&gU zZQV*^EQZ~$4`v|IgM-KUlowFe;!v(Ft;hS7Jws~TD5mNo=)YvT*^cyUKz`=LpQi5_ z4{#yfrtFqEiY_H7L9Kax)2A9F-8!qH2|t%<_-s4+zXW*B}KcX9^ke& z+)ARr>&zSlx-)0@GJ=jTnamRWknXSz0*vyvdlM& zshU9NKd57BX26JBacUF}AXR>#ad`A*X3Ysed_s7kMk9Rkobgb8wUw>U_?yPVaCc}m zW$t%&8&mk2;2~{sQt711tFN{wn>Zr>035)r$_uvi00c#z+znPp0;>{08_P7cL{CI#sV3fz*W>`}~=+E0Y)j3RTFR^s%#3m?B8uw)T zv7RLiSQh9$ram53p~Pn6Y{A%`CLn#ns3l$u7k=Q&nPMg)@F7e0GZz?#P0@D7XO`3umH(Y z@L-r5%yNVrXN&rYC=4*H-ElXPR-xI{V7)B33AAO+xR`ZT%J67-jW)Z-{-djF6wqU! z5xZP^i!B+K4N9J6X#Ec3sDN3k9$*k5d&sB;=)Vj?xGyq_n_*f!!d_UZ{M;Cb-pdD^ z;xmsTECo+cI zVWem}Woah42YG;&$w9OE?hgrrc*XuBu{E2|^(rozvN3O%HngvKA9A+Zy37|gA-jnH zM!||%r%6ZRXjHmS?o&24p+itXR*C_DTFWac%W~!mJdCX;PTSpZYkkH_}?(R|rmpZLDE` zKADUjpjA~i29xSEAjArP+l`iB&mItWp5>=V~; z5z9oSc*OJJYpr50U}#gAFhkn3-M|9P(+~V51^^YNy}+nT9>CzZf(6(&QzUC<8t17) zu}ZUf@eBzn920+-os0*&fmLRJcEOF4VyYw{@i@a;<(2?eqf6du$LpAQ92Bwa>u20; zMCSneS|b3*yH4mBtkdiYHKip?v|;|sj-Zr~!IF0LTD z-ua`%Igw>xCjq8gk=l#iuBz*tLxMJuK8;MdZKAU4H;yGU(q$1d-JLfcioXVpqaJKgX6=bx@@FcJW`YJX!4CJi;05K4{bAXPP(K%g8Z^Rr2Z{j0P zlH3PJP5L+!Nm%)Efv668RtNrEH|F8pUPO{ga> zI*b~bL7IBG>czG9TVIJ;#HoOWknPP|h8i8+r(0oL^$^ip^GdI0hpBuG0RW*GTEWx3 z%{?GC99`itQoIXBwh2yMy^*!T?30Y{S~Myy_6Y!2ByfAP#J{4rAJkBpRDUE%Hb4s2 z@+$nyKxa*7A;PS>ghMd7^l`3AB8G}_h?b*DhIp2tVd>&xyw#VQmrGD|uM+SUC9?zb zCNrDrDUI6BXpe;pvxR`z2eF}Hp&WqVyW0^UgP<4y;Es8RcxADyQ(a4{0K=@JWGcEn z1YlscK8iN^mV8%s=WV{D-I2Cr%H#$81Lh!Og$66p99+3rDu9g6s(>gCswg&^c5e|C zM1>7AIu>jbPP#xUu?^KNj!tu!qZR8gjV+2Yt0;KkX@F)?+$cEj?qjHSF{?g6%WQ-M z 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 M?2O!)mGXc8*|P2~xBvhE literal 0 HcmV?d00001 diff --git a/packages/astro/test/fixtures/core-image-deletion-ssr/src/assets/twoofus.jpg b/packages/astro/test/fixtures/core-image-deletion-ssr/src/assets/twoofus.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e859ac3c992f38d8e588390f34a36102e6547dbc GIT binary patch literal 11677 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 bLZz}}%%aX~#2Qf?U@JJ3E+P>6XX*a{3I + + +

SSR Page

+ This image is also used in the prerendered page, so the original must be kept + + diff --git a/packages/astro/test/fixtures/core-image-deletion-ssr/src/pages/prerendered.astro b/packages/astro/test/fixtures/core-image-deletion-ssr/src/pages/prerendered.astro new file mode 100644 index 000000000000..6ce8149283cc --- /dev/null +++ b/packages/astro/test/fixtures/core-image-deletion-ssr/src/pages/prerendered.astro @@ -0,0 +1,18 @@ +--- +import { Image } from "astro:assets"; +import onlyOne from "../assets/onlyone.jpg"; +import twoOfUs from "../assets/twoofus.jpg"; +import shared from "../assets/shared.jpg"; +export const prerender = true; +--- + + + + Only one of me exists at the end of the build + + Two of us will exist, because I'm also used as a normal image + Two of us will exist, because I'm also used as a normal image + + I'm only optimized here, but the SSR page uses my original directly + + diff --git a/packages/astro/test/image-deletion.test.js b/packages/astro/test/image-deletion.test.js index 9af94a7543e1..b89d2551617d 100644 --- a/packages/astro/test/image-deletion.test.js +++ b/packages/astro/test/image-deletion.test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { before, describe, it } from 'node:test'; +import testAdapter from './test-adapter.js'; import { testImageService } from './test-image-service.js'; import { loadFixture } from './test-utils.js'; @@ -44,4 +45,34 @@ describe('astro:assets - delete images that are unused', () => { assert.equal(imagesUsedElsewhere.length, 2); }); }); + + describe('build ssr', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/core-image-deletion-ssr/', + output: 'server', + adapter: testAdapter(), + image: { + service: testImageService(), + }, + }); + + await fixture.build(); + }); + + it('should delete prerendered images that are only used for optimization', async () => { + const imagesOnlyOptimized = await fixture.glob('client/_astro/onlyone.*.*'); + assert.equal(imagesOnlyOptimized.length, 1); + }); + + it('should not delete prerendered images that are used in other contexts', async () => { + const imagesUsedElsewhere = await fixture.glob('client/_astro/twoofus.*.*'); + assert.equal(imagesUsedElsewhere.length, 2); + }); + + it('should not delete images that are used in both a prerendered and an SSR page', async () => { + const imagesUsedInBoth = await fixture.glob('client/_astro/shared.*.*'); + assert.equal(imagesUsedInBoth.length, 2); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a574ab588de5..b0b7f6589c69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2880,6 +2880,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/core-image-deletion-ssr: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/core-image-errors: dependencies: astro: From 32b430213bbe51898b77ec2eadbacb9dfb220f75 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Tue, 24 Feb 2026 16:00:21 +0000 Subject: [PATCH 05/10] feat(runtime): use queued based rendering (#15471) Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> Co-authored-by: Yan <61414485+yanthomasdev@users.noreply.github.com> Co-authored-by: Armand Philippot --- .changeset/hot-eyes-sink.md | 63 ++++ .github/workflows/continuous_benchmark.yml | 2 +- benchmark/packages/adapter/src/index.ts | 2 +- benchmark/packages/adapter/src/server.ts | 2 + .../build-hybrid/astro.config.js | 2 +- .../astro/src/assets/vite-plugin-assets.ts | 8 +- packages/astro/src/container/index.ts | 4 + packages/astro/src/core/app/dev/pipeline.ts | 7 + packages/astro/src/core/app/manifest.ts | 15 + packages/astro/src/core/app/pipeline.ts | 1 - packages/astro/src/core/app/types.ts | 7 + packages/astro/src/core/base-pipeline.ts | 23 ++ packages/astro/src/core/build/app.ts | 7 + packages/astro/src/core/build/generate.ts | 29 +- packages/astro/src/core/build/pipeline.ts | 7 + .../src/core/build/plugins/plugin-manifest.ts | 5 + .../astro/src/core/config/schemas/base.ts | 11 + packages/astro/src/core/render-context.ts | 7 + packages/astro/src/manifest/serialized.ts | 1 + .../src/runtime/server/html-string-cache.ts | 74 ++++ packages/astro/src/runtime/server/index.ts | 1 + .../src/runtime/server/render/astro/render.ts | 29 ++ .../astro/src/runtime/server/render/page.ts | 65 +++- .../runtime/server/render/queue/builder.ts | 290 +++++++++++++++ .../server/render/queue/jsx-builder.ts | 271 ++++++++++++++ .../src/runtime/server/render/queue/pool.ts | 349 ++++++++++++++++++ .../runtime/server/render/queue/renderer.ts | 166 +++++++++ .../src/runtime/server/render/queue/types.ts | 93 +++++ packages/astro/src/types/public/config.ts | 60 +++ packages/astro/src/types/public/internal.ts | 16 + .../astro/src/vite-plugin-app/pipeline.ts | 7 + .../src/vite-plugin-astro-server/plugin.ts | 4 + .../jsx-queue-rendering/astro.config.mjs | 12 + .../fixtures/jsx-queue-rendering/package.json | 9 + .../src/pages/mdx-nested.mdx | 35 ++ .../src/pages/mdx-simple.mdx | 17 + .../src/pages/nested.astro | 24 ++ .../src/pages/simple.astro | 17 + .../src/pages/special-elements.astro | 19 + .../fixtures/queue-rendering/astro.config.mjs | 11 + .../fixtures/queue-rendering/package.json | 11 + .../src/components/Counter.jsx | 12 + .../src/components/Nested.astro | 6 + .../queue-rendering/src/components/Static.jsx | 7 + .../src/components/WithHead.astro | 17 + .../src/components/WithSlot.astro | 9 + .../src/pages/client-components.astro | 42 +++ .../src/pages/directives.astro | 33 ++ .../src/pages/head-content.astro | 31 ++ .../queue-rendering/src/pages/index.astro | 39 ++ .../astro/test/jsx-queue-rendering.test.js | 71 ++++ packages/astro/test/queue-rendering.test.js | 299 +++++++++++++++ packages/astro/test/units/app/test-helpers.js | 3 + .../test/units/render/queue-batching.test.js | 169 +++++++++ .../render/queue-pool-content-cache.test.js | 178 +++++++++ .../render/queue-pool-prewarming.test.js | 165 +++++++++ .../test/units/render/queue-pool.test.js | 138 +++++++ .../test/units/render/queue-rendering.test.js | 263 +++++++++++++ packages/astro/test/units/test-utils.js | 3 + packages/integrations/mdx/src/server.ts | 4 + pnpm-lock.yaml | 24 ++ 61 files changed, 3276 insertions(+), 20 deletions(-) create mode 100644 .changeset/hot-eyes-sink.md create mode 100644 packages/astro/src/runtime/server/html-string-cache.ts create mode 100644 packages/astro/src/runtime/server/render/queue/builder.ts create mode 100644 packages/astro/src/runtime/server/render/queue/jsx-builder.ts create mode 100644 packages/astro/src/runtime/server/render/queue/pool.ts create mode 100644 packages/astro/src/runtime/server/render/queue/renderer.ts create mode 100644 packages/astro/src/runtime/server/render/queue/types.ts create mode 100644 packages/astro/test/fixtures/jsx-queue-rendering/astro.config.mjs create mode 100644 packages/astro/test/fixtures/jsx-queue-rendering/package.json create mode 100644 packages/astro/test/fixtures/jsx-queue-rendering/src/pages/mdx-nested.mdx create mode 100644 packages/astro/test/fixtures/jsx-queue-rendering/src/pages/mdx-simple.mdx create mode 100644 packages/astro/test/fixtures/jsx-queue-rendering/src/pages/nested.astro create mode 100644 packages/astro/test/fixtures/jsx-queue-rendering/src/pages/simple.astro create mode 100644 packages/astro/test/fixtures/jsx-queue-rendering/src/pages/special-elements.astro create mode 100644 packages/astro/test/fixtures/queue-rendering/astro.config.mjs create mode 100644 packages/astro/test/fixtures/queue-rendering/package.json create mode 100644 packages/astro/test/fixtures/queue-rendering/src/components/Counter.jsx create mode 100644 packages/astro/test/fixtures/queue-rendering/src/components/Nested.astro create mode 100644 packages/astro/test/fixtures/queue-rendering/src/components/Static.jsx create mode 100644 packages/astro/test/fixtures/queue-rendering/src/components/WithHead.astro create mode 100644 packages/astro/test/fixtures/queue-rendering/src/components/WithSlot.astro create mode 100644 packages/astro/test/fixtures/queue-rendering/src/pages/client-components.astro create mode 100644 packages/astro/test/fixtures/queue-rendering/src/pages/directives.astro create mode 100644 packages/astro/test/fixtures/queue-rendering/src/pages/head-content.astro create mode 100644 packages/astro/test/fixtures/queue-rendering/src/pages/index.astro create mode 100644 packages/astro/test/jsx-queue-rendering.test.js create mode 100644 packages/astro/test/queue-rendering.test.js create mode 100644 packages/astro/test/units/render/queue-batching.test.js create mode 100644 packages/astro/test/units/render/queue-pool-content-cache.test.js create mode 100644 packages/astro/test/units/render/queue-pool-prewarming.test.js create mode 100644 packages/astro/test/units/render/queue-pool.test.js create mode 100644 packages/astro/test/units/render/queue-rendering.test.js diff --git a/.changeset/hot-eyes-sink.md b/.changeset/hot-eyes-sink.md new file mode 100644 index 000000000000..8787dcbdd372 --- /dev/null +++ b/.changeset/hot-eyes-sink.md @@ -0,0 +1,63 @@ +--- +'astro': minor +--- + +Adds a new experimental flag `queuedRendering` to enable a queue-based rendering engine + +The new engine is based on a two-pass process, where the first pass +traverses the tree of components, emits an ordered queue, and then the queue is rendered. + +The new engine does not use recursion, and comes with two customizable options. + +Early benchmarks showed significant speed improvements and memory efficiency in big projects. + +#### Queue-rendered based + +The new engine can be enabled in your Astro config with `experimental.queuedRendering.enabled` set to `true`, and can be further customized with additional sub-features. + +```js +// astro.config.mjs +export default defineConfig({ + experimental: { + queuedRendering: { + enabled: true + } + } +}) +``` + +#### Pooling + +With the new engine enabled, you now have the option to have a pool of nodes that can be saved and reused across page rendering. Node pooling has no effect when rendering pages on demand (SSR) because these rendering requests don't share memory. However, it can be very useful for performance when building static pages. + +```js +// astro.config.mjs +export default defineConfig({ + experimental: { + queuedRendering: { + enabled: true, + poolSize: 2000 // store up to 2k nodes to be reused across renderers + } + } +}); +``` + +#### Content caching + +The new engine additionally unlocks a new `contentCache` option. This allows you to cache values of nodes during the rendering phase. This is currently a boolean feature with no further customization (e.g. size of cache) that uses sensible defaults for most large content collections: + +When disabled, the pool engine won't cache strings, but only types. + +```js +// astro.config.mjs +export default defineConfig({ + experimental: { + queuedRendering: { + enabled: true, + contentCache: true // enable re-use of node values + } + } +}); +``` + +For more information on enabling and using this feature in your project, see the [experimental queued rendering docs](https://v6.docs.astro.build/en/reference/experimental-flags/queued-rendering/) for more details. diff --git a/.github/workflows/continuous_benchmark.yml b/.github/workflows/continuous_benchmark.yml index df4ff740b8de..7b0cbad07729 100644 --- a/.github/workflows/continuous_benchmark.yml +++ b/.github/workflows/continuous_benchmark.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - shard: [ "1/4","2/4", "3/4", "4/4" ] + shard: [ "1/4", "2/4", "3/4", "4/4" ] permissions: contents: read pull-requests: write diff --git a/benchmark/packages/adapter/src/index.ts b/benchmark/packages/adapter/src/index.ts index 0fc6d67f99c2..ee25e46bdf63 100644 --- a/benchmark/packages/adapter/src/index.ts +++ b/benchmark/packages/adapter/src/index.ts @@ -17,7 +17,7 @@ export default function createIntegration(): AstroIntegration { setAdapter({ name: '@benchmark/adapter', serverEntrypoint: '@benchmark/adapter/server.js', - exports: ['manifest', 'createApp'], + exports: ['manifest', 'createApp', 'App'], supportedAstroFeatures: { serverOutput: 'stable', envGetSecret: 'experimental', diff --git a/benchmark/packages/adapter/src/server.ts b/benchmark/packages/adapter/src/server.ts index 2a4d45f443d1..b0dcc549dbde 100644 --- a/benchmark/packages/adapter/src/server.ts +++ b/benchmark/packages/adapter/src/server.ts @@ -38,5 +38,7 @@ export function createExports(manifest: SSRManifest) { return { manifest, createApp: (streaming: boolean) => new MyApp(manifest, streaming), + // Export App class directly for benchmarks that need to pass custom manifests + App: MyApp, }; } diff --git a/benchmark/static-projects/build-hybrid/astro.config.js b/benchmark/static-projects/build-hybrid/astro.config.js index 07d9b272b443..1433c4568de8 100644 --- a/benchmark/static-projects/build-hybrid/astro.config.js +++ b/benchmark/static-projects/build-hybrid/astro.config.js @@ -4,5 +4,5 @@ import node from '@astrojs/node'; export default defineConfig({ adapter: node({ mode: 'standalone', - }), + }) }); diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index f0697c357d3b..89c3d802975d 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -168,10 +168,10 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro"; import { inferRemoteSize as inferRemoteSizeInternal } from "astro/assets/utils/inferRemoteSize.js"; - export { default as Font } from "astro/components/Font.astro"; - export * from "${RUNTIME_VIRTUAL_MODULE_ID}"; - - export const getConfiguredImageService = _getConfiguredImageService; + export { default as Font } from "astro/components/Font.astro"; + export * from "${RUNTIME_VIRTUAL_MODULE_ID}"; + + export const getConfiguredImageService = _getConfiguredImageService; export const viteFSConfig = ${JSON.stringify(resolvedConfig.server.fs ?? {})}; diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index 44313507c065..6a887277d035 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -176,6 +176,9 @@ function createManifest( placement: undefined, }, logLevel: 'silent', + experimentalQueuedRendering: manifest?.experimentalQueuedRendering ?? { + enabled: false, + }, }; } @@ -266,6 +269,7 @@ type AstroContainerManifest = Pick< | 'serverLike' | 'assetsDir' | 'image' + | 'experimentalQueuedRendering' >; type AstroContainerConstructor = { diff --git a/packages/astro/src/core/app/dev/pipeline.ts b/packages/astro/src/core/app/dev/pipeline.ts index cd33412f45de..7e1b44cd7c67 100644 --- a/packages/astro/src/core/app/dev/pipeline.ts +++ b/packages/astro/src/core/app/dev/pipeline.ts @@ -9,6 +9,9 @@ import { type HeadElements, Pipeline, type TryRewriteResult } from '../../base-p import { ASTRO_VERSION } from '../../constants.js'; import { createModuleScriptElement, createStylesheetElementSet } from '../../render/ssr-element.js'; import { findRouteToRewrite } from '../../routing/rewrite.js'; +import { newNodePool } from '../../../runtime/server/render/queue/pool.js'; +import { HTMLStringCache } from '../../../runtime/server/html-string-cache.js'; +import { queueRenderingEnabled } from '../manifest.js'; type DevPipelineCreate = Pick; @@ -45,6 +48,10 @@ export class NonRunnablePipeline extends Pipeline { undefined, undefined, ); + if (queueRenderingEnabled(manifest.experimentalQueuedRendering)) { + pipeline.nodePool = newNodePool(manifest.experimentalQueuedRendering!); + pipeline.htmlStringCache = new HTMLStringCache(1000); // Use default size + } return pipeline; } diff --git a/packages/astro/src/core/app/manifest.ts b/packages/astro/src/core/app/manifest.ts index 76fc21a16b4d..794582a0e62f 100644 --- a/packages/astro/src/core/app/manifest.ts +++ b/packages/astro/src/core/app/manifest.ts @@ -129,3 +129,18 @@ export function deserializeRouteInfo(rawRouteInfo: SerializedRouteInfo): RouteIn routeData: deserializeRouteData(rawRouteInfo.routeData), }; } + +export function queuePoolSize( + config: NonNullable, +): number { + return config?.poolSize ?? 1000; +} +export function queueContentCache( + config: NonNullable, +): boolean { + return config?.contentCache ?? false; +} + +export function queueRenderingEnabled(config: SSRManifest['experimentalQueuedRendering']): boolean { + return config?.enabled ?? false; +} diff --git a/packages/astro/src/core/app/pipeline.ts b/packages/astro/src/core/app/pipeline.ts index 4cd1c0b74bee..860a23c9749b 100644 --- a/packages/astro/src/core/app/pipeline.ts +++ b/packages/astro/src/core/app/pipeline.ts @@ -12,7 +12,6 @@ import { import { getFallbackRoute, routeIsFallback, routeIsRedirect } from '../routing/helpers.js'; import { findRouteToRewrite } from '../routing/rewrite.js'; import { createConsoleLogger } from './logging.js'; - export class AppPipeline extends Pipeline { getName(): string { return 'AppPipeline'; diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 99646ca2a12f..48c0e008653e 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -73,6 +73,13 @@ export type SSRManifest = { trailingSlash: AstroConfig['trailingSlash']; buildFormat: NonNullable['format']; compressHTML: boolean; + experimentalQueuedRendering: { + enabled: boolean; + /** Node pool size for memory reuse (default: 1000, set to 0 to disable pooling) */ + poolSize?: number; + /** Whether to enable HTMLString caching (default: true) */ + contentCache?: boolean; + }; assetsPrefix?: AssetsPrefix; renderers: SSRLoadedRenderer[]; /** diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index 7ce16bf862ba..c35e5fa1816d 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -24,6 +24,8 @@ import { RedirectSinglePageBuiltModule } from './redirects/index.js'; import { RouteCache } from './render/route-cache.js'; import { createDefaultRoutes } from './routing/default.js'; import type { SessionDriverFactory } from './session/types.js'; +import { NodePool } from '../runtime/server/render/queue/pool.js'; +import { HTMLStringCache } from '../runtime/server/html-string-cache.js'; /** * The `Pipeline` represents the static parts of rendering that do not change between requests. @@ -36,6 +38,8 @@ export abstract class Pipeline { resolvedMiddleware: MiddlewareHandler | undefined = undefined; resolvedActions: SSRActions | undefined = undefined; resolvedSessionDriver: SessionDriverFactory | null | undefined = undefined; + nodePool: NodePool | undefined; + htmlStringCache: HTMLStringCache | undefined; constructor( readonly logger: Logger, @@ -79,6 +83,17 @@ export abstract class Pipeline { createI18nMiddleware(i18n, manifest.base, manifest.trailingSlash, manifest.buildFormat), ); } + + if (manifest.experimentalQueuedRendering.enabled) { + this.nodePool = this.createNodePool( + manifest.experimentalQueuedRendering.poolSize ?? 1000, + manifest.experimentalQueuedRendering.contentCache ?? false, + false, + ); + if (manifest.experimentalQueuedRendering.contentCache) { + this.htmlStringCache = this.createStringCache(); + } + } } abstract headElements(routeData: RouteData): Promise | HeadElements; @@ -228,6 +243,14 @@ export abstract class Pipeline { ); } } + + public createNodePool(poolSize: number, contentCache: boolean, stats: boolean): NodePool { + return new NodePool(poolSize, contentCache, stats); + } + + public createStringCache(): HTMLStringCache { + return new HTMLStringCache(1000); + } } // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/packages/astro/src/core/build/app.ts b/packages/astro/src/core/build/app.ts index aae4c30af6d9..80e0b6ef47c2 100644 --- a/packages/astro/src/core/build/app.ts +++ b/packages/astro/src/core/build/app.ts @@ -5,6 +5,7 @@ import { BuildPipeline } from './pipeline.js'; import type { StaticBuildOptions } from './types.js'; import type { CreateRenderContext, RenderContext } from '../render-context.js'; import type { LogRequestPayload } from '../app/base.js'; +import type { PoolStatsReport } from '../../runtime/server/render/queue/pool.js'; export class BuildApp extends BaseApp { createPipeline(_streaming: boolean, manifest: SSRManifest, ..._args: any[]): BuildPipeline { @@ -54,5 +55,11 @@ export class BuildApp extends BaseApp { } } + getQueueStats(): PoolStatsReport | undefined { + if (this.pipeline.nodePool) { + return this.pipeline.nodePool.getStats(); + } + } + logRequest(_options: LogRequestPayload) {} } diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 8e646446f2ca..b49e726be423 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -29,7 +29,7 @@ import { routeIsRedirect } from '../routing/helpers.js'; import { matchRoute } from '../routing/match.js'; import { getOutputFilename } from '../util.js'; import { getOutFile, getOutFolder } from './common.js'; -import { createDefaultPrerenderer } from './default-prerenderer.js'; +import { createDefaultPrerenderer, type DefaultPrerenderer } from './default-prerenderer.js'; import { type BuildInternals, hasPrerenderedPages } from './internal.js'; import type { StaticBuildOptions } from './types.js'; import type { AstroSettings } from '../../types/astro.js'; @@ -58,7 +58,7 @@ export async function generatePages( } // Get or create the prerenderer - let prerenderer: AstroPrerenderer; + let prerenderer: DefaultPrerenderer; const settingsPrerenderer = options.settings.prerenderer; if (!settingsPrerenderer) { // No custom prerenderer - create default @@ -199,6 +199,31 @@ export async function generatePages( colors.green(`✓ Completed in ${getTimeStat(generatePagesTimer, performance.now())}.\n`), ); + // Log pool statistics if queue rendering is enabled + if ( + options.settings.logLevel === 'debug' && + options.settings.config.experimental?.queuedRendering && + prerenderer.app + ) { + try { + const stats = prerenderer.app.getQueueStats(); + // Dynamic import to avoid loading pool module when not using queue rendering + // Only log if there was actual pool activity + if (stats && (stats.acquireFromPool > 0 || stats.acquireNew > 0)) { + logger.info( + null, + colors.dim( + `[Queue Pool] ${stats.acquireFromPool.toLocaleString()} reused / ${stats.acquireNew.toLocaleString()} new nodes | ` + + `Hit rate: ${stats.hitRate.toFixed(1)}% | ` + + `Pool: ${stats.poolSize}/${stats.maxSize}`, + ), + ); + } + } catch { + // Silently ignore if pool module is not available + } + } + // Default pipeline always runs if (staticImageList.size) { logger.info('SKIP_FORMAT', `${colors.bgGreen(colors.black(` generating optimized images `))}`); diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts index 5e835607d389..2ce947aa52d2 100644 --- a/packages/astro/src/core/build/pipeline.ts +++ b/packages/astro/src/core/build/pipeline.ts @@ -16,6 +16,9 @@ import { findRouteToRewrite } from '../routing/rewrite.js'; import type { BuildInternals } from './internal.js'; import { cssOrder, mergeInlineCss, getPageData } from './runtime.js'; import type { SinglePageBuiltModule, StaticBuildOptions } from './types.js'; +import { newNodePool } from '../../runtime/server/render/queue/pool.js'; +import { HTMLStringCache } from '../../runtime/server/html-string-cache.js'; +import { queueRenderingEnabled } from '../app/manifest.js'; /** * The build pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files. @@ -82,6 +85,10 @@ export class BuildPipeline extends Pipeline { const logger = createConsoleLogger(manifest.logLevel); // We can skip streaming in SSG for performance as writing as strings are faster super(logger, manifest, 'production', manifest.renderers, resolve, manifest.serverLike); + if (queueRenderingEnabled(this.manifest.experimentalQueuedRendering)) { + this.nodePool = newNodePool(this.manifest.experimentalQueuedRendering!); + this.htmlStringCache = new HTMLStringCache(1000); // Use default size + } } getRoutes(): RouteData[] { diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 8ef56913e876..fc5b05c4213a 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -321,6 +321,11 @@ async function buildManifest( trailingSlash: settings.config.trailingSlash, compressHTML: settings.config.compressHTML, assetsPrefix: settings.config.build.assetsPrefix, + experimentalQueuedRendering: { + enabled: settings.config.experimental.queuedRendering?.enabled ?? false, + poolSize: 0, + contentCache: false, + }, componentMetadata: Array.from(internals.componentMetadata), renderers: [], clientDirectives: Array.from(settings.clientDirectives), diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 88b159485167..477acacea1f0 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -105,6 +105,9 @@ export const ASTRO_CONFIG_DEFAULTS = { chromeDevtoolsWorkspace: false, svgo: false, rustCompiler: false, + queuedRendering: { + enabled: false, + }, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -495,6 +498,14 @@ export const AstroConfigSchema = z.object({ .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.svgo), rustCompiler: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.rustCompiler), + queuedRendering: z + .object({ + enabled: z.boolean().optional().prefault(false), + poolSize: z.number().int().positive().optional(), + contentCache: z.boolean().optional(), + }) + .optional() + .prefault(ASTRO_CONFIG_DEFAULTS.experimental.queuedRendering), }) .prefault({}), legacy: z diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 238cb466a940..ca52a80bf4b9 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -584,6 +584,13 @@ export class RenderContext { serverIslandNameMap: this.serverIslands.serverIslandNameMap ?? new Map(), key: manifest.key, trailingSlash: manifest.trailingSlash, + _experimentalQueuedRendering: { + pool: pipeline.nodePool, + htmlStringCache: pipeline.htmlStringCache, + enabled: manifest.experimentalQueuedRendering?.enabled, + poolSize: manifest.experimentalQueuedRendering?.poolSize, + contentCache: manifest.experimentalQueuedRendering?.contentCache, + }, _metadata: { hasHydrationScript: false, rendererSpecificHydrationScripts: new Set(), diff --git a/packages/astro/src/manifest/serialized.ts b/packages/astro/src/manifest/serialized.ts index 8560496147e4..f2b5e3e5ca0b 100644 --- a/packages/astro/src/manifest/serialized.ts +++ b/packages/astro/src/manifest/serialized.ts @@ -185,5 +185,6 @@ async function createSerializedManifest(settings: AstroSettings): Promise` tags → single cached HTMLString object + * - Memory savings: ~30KB (10,000 objects) → ~3 bytes (1 object + Map overhead) + */ +export class HTMLStringCache { + private cache = new Map(); + private readonly maxSize: number; + + constructor(maxSize = 1000) { + this.maxSize = maxSize; + } + + /** + * Get or create an HTMLString for the given content. + * If cached, the existing object is returned and moved to end (most recently used). + * If not cached, a new HTMLString is created, cached, and returned. + * + * @param content - The HTML string content + * @returns HTMLString object (cached or newly created) + */ + getOrCreate(content: string): HTMLString { + // Check cache + const cached = this.cache.get(content); + if (cached) { + // LRU: move to end (most recently used) + // Maps maintain insertion order, so delete + set moves to end + this.cache.delete(content); + this.cache.set(content, cached); + return cached; + } + + // Create new HTMLString + const htmlString = new HTMLString(content); + + // Add to cache + this.cache.set(content, htmlString); + + // Evict least recently used if over size + // The first key in the Map is the oldest (least recently used) + if (this.cache.size > this.maxSize) { + const firstKey = this.cache.keys().next().value; + if (firstKey !== undefined) { + this.cache.delete(firstKey); + } + } + + return htmlString; + } + + /** + * Get current cache size + */ + size(): number { + return this.cache.size; + } + + /** + * Clear the entire cache + */ + clear(): void { + this.cache.clear(); + } +} diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 56f85088c7ca..e276bcce5c3b 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -11,6 +11,7 @@ export { markHTMLString, unescapeHTML, } from './escape.js'; + export { renderJSX } from './jsx.js'; export type { AstroComponentFactory, diff --git a/packages/astro/src/runtime/server/render/astro/render.ts b/packages/astro/src/runtime/server/render/astro/render.ts index d6dec2b8b724..e2ce59a0b0b8 100644 --- a/packages/astro/src/runtime/server/render/astro/render.ts +++ b/packages/astro/src/runtime/server/render/astro/render.ts @@ -15,6 +15,11 @@ import { isRenderTemplateResult } from './render-template.js'; const DOCTYPE_EXP = / = { ...(props ?? {}), 'server:root': true }; - const str = await renderComponentToString( - result, - componentFactory.name, - componentFactory, - pageProps, - {}, - true, - route, - ); + let str: string; + + // Check if queue rendering is enabled + if (result._experimentalQueuedRendering && result._experimentalQueuedRendering.enabled) { + // Queue rendering: Call the component to get the render result, + // then process it through the queue system + + // Call the component function to get the vnode tree + const vnode = await (componentFactory as any)(pageProps); + + // Build a render queue from the vnode tree + const queue = await buildRenderQueue( + vnode, + result, + result._experimentalQueuedRendering.pool!, + ); + + // Render the queue to a string + let html = ''; + let renderedFirst = false; + const destination = { + write(chunk: any) { + if (chunk instanceof Response) return; + + // Add doctype if this is the first chunk and it doesn't already have one + if (!renderedFirst && !result.partial) { + renderedFirst = true; + const chunkStr = String(chunk); + if (!/' : '\n'; + html += doctype; + } + } + + html += chunkToString(result, chunk); + }, + }; + await renderQueue(queue, destination); + str = html; + } else { + // Standard rendering path (non-MDX or queue rendering disabled) + str = await renderComponentToString( + result, + componentFactory.name, + componentFactory, + pageProps, + {}, + true, + route, + ); + } const bytes = encoder.encode(str); const headers = new Headers([ diff --git a/packages/astro/src/runtime/server/render/queue/builder.ts b/packages/astro/src/runtime/server/render/queue/builder.ts new file mode 100644 index 000000000000..1af3f9a7f608 --- /dev/null +++ b/packages/astro/src/runtime/server/render/queue/builder.ts @@ -0,0 +1,290 @@ +import type { SSRResult } from '../../../../types/public/internal.js'; +import { isPromise } from '../../util.js'; +import { isHTMLString, markHTMLString } from '../../escape.js'; +import { isAstroComponentFactory, isAPropagatingComponent } from '../astro/factory.js'; +import { createAstroComponentInstance, isAstroComponentInstance } from '../astro/instance.js'; +import { isRenderInstance } from '../common.js'; +import { isRenderInstruction } from '../instruction.js'; +import { SlotString } from '../slot.js'; +import type { + RenderQueue, + StackItem, + TextNode, + HtmlStringNode, + ComponentNode, + InstructionNode, +} from './types.js'; +import { isRenderTemplateResult } from '../astro/render-template.js'; +import { isHeadAndContent } from '../astro/head-and-content.js'; +import { isVNode } from '../../../../jsx-runtime/index.js'; +import { renderJSXToQueue } from './jsx-builder.js'; +import type { NodePool } from './pool.js'; + +/** + * Builds a render queue from a component tree. + * This function traverses the tree depth-first and creates a flat queue + * of nodes to be rendered, with parent tracking. + * + * @param root - The root component/value to render + * @param result - SSR result context + * @param pool + * @returns A render queue ready for rendering + */ +export async function buildRenderQueue( + root: any, + result: SSRResult, + pool: NodePool, +): Promise { + const queue: RenderQueue = { + nodes: [], + result, + pool, + htmlStringCache: result._experimentalQueuedRendering?.htmlStringCache, + }; + + // Stack for depth-first traversal (LIFO - Last In, First Out) + const stack: StackItem[] = [{ node: root, parent: null }]; + + // Process nodes depth-first + while (stack.length > 0) { + const item = stack.pop(); + if (!item) { + continue; + } + let { node, parent } = item; + + // Handle promises immediately (wait for resolution) + if (isPromise(node)) { + try { + const resolved = await node; + // Push resolved value back onto stack + stack.push({ node: resolved, parent, metadata: item.metadata }); + } catch (error) { + // Stop on first error as requested + throw error; + } + continue; + } + + // Skip null, undefined, false (but not 0) + if (node == null || node === false) { + continue; + } + + // Handle different node types + if (typeof node === 'string') { + // Plain text content - use content-aware caching + // Acquire('text', ...) returns TextNode, so we can safely assert + const queueNode = pool.acquire('text', node) as TextNode; + queueNode.content = node; + queue.nodes.push(queueNode); + continue; + } + + if (typeof node === 'number' || typeof node === 'boolean') { + // Convert to string - use content-aware caching + const str = String(node); + const queueNode = pool.acquire('text', str) as TextNode; + queueNode.content = str; + queue.nodes.push(queueNode); + continue; + } + + // Handle HTML strings (marked as safe) - use content-aware caching + if (isHTMLString(node)) { + const html = node.toString(); + const queueNode = pool.acquire('html-string', html) as HtmlStringNode; + queueNode.html = html; + queue.nodes.push(queueNode); + continue; + } + + // Handle SlotString - use content-aware caching + if (node instanceof SlotString) { + const html = node.toString(); + const queueNode = pool.acquire('html-string', html) as HtmlStringNode; + queueNode.html = html; + queue.nodes.push(queueNode); + continue; + } + + // Handle JSX VNodes (from MDX or JSX expressions) + if (isVNode(node)) { + // Process JSX VNode through the JSX builder + // This will push the VNode and its children onto the stack for processing + renderJSXToQueue(node, result, queue, pool, stack, parent, item.metadata); + continue; + } + + // Handle arrays + if (Array.isArray(node)) { + // Push children onto stack (they'll be popped in reverse, then final queue is reversed) + for (const n of node) { + stack.push({ node: n, parent, metadata: item.metadata }); + } + continue; + } + + // Handle render instructions (head, hydration scripts, etc.) + if (isRenderInstruction(node)) { + const queueNode = pool.acquire('instruction') as InstructionNode; + queueNode.instruction = node; + queue.nodes.push(queueNode); + continue; + } + + // Handle RenderTemplateResult (Astro template literals) + if (isRenderTemplateResult(node)) { + // Process htmlParts and expressions + // We need to interleave htmlParts with expressions + const htmlParts = node['htmlParts']; + const expressions = node['expressions']; + + // Push in forward order - stack pop() will reverse, then final reverse() corrects it + // Push first HTML part - mark as HTMLString so it won't be escaped + if (htmlParts[0]) { + const htmlString = queue.htmlStringCache + ? queue.htmlStringCache.getOrCreate(htmlParts[0]) + : markHTMLString(htmlParts[0]); + stack.push({ + node: htmlString, + parent, + metadata: item.metadata, + }); + } + + // Interleave expressions and HTML parts + for (let i = 0; i < expressions.length; i = i + 1) { + // Push expression + stack.push({ node: expressions[i], parent, metadata: item.metadata }); + // Push HTML part after expression - mark as HTMLString + if (htmlParts[i + 1]) { + const htmlString = queue.htmlStringCache + ? queue.htmlStringCache.getOrCreate(htmlParts[i + 1]) + : markHTMLString(htmlParts[i + 1]); + stack.push({ + node: htmlString, + parent, + metadata: item.metadata, + }); + } + } + continue; + } + + // Handle Astro component instances + if (isAstroComponentInstance(node)) { + // This is already an instance, create queue node + const queueNode = pool.acquire('component') as ComponentNode; + queueNode.instance = node; + + // Check if this is a propagator + // Note: We can't easily check isAPropagatingComponent here because we need the factory + // This will be handled later when we have access to the factory metadata + + queue.nodes.push(queueNode); + + // We'll need to render this component to get its children + // For now, we'll handle this in the renderer + continue; + } + + // Handle Astro component factories + if (isAstroComponentFactory(node)) { + const factory = node; + const props = item.metadata?.props || {}; + const slots = item.metadata?.slots || {}; + const displayName = item.metadata?.displayName || factory.name || 'Anonymous'; + + // Create component instance + const instance = createAstroComponentInstance(result, displayName, factory, props, slots); + + const queueNode = pool.acquire('component') as ComponentNode; + queueNode.instance = instance; + + // Check if this component is a propagator (provides head content) + if (isAPropagatingComponent(result, factory)) { + // Initialize propagator to collect head content + try { + const returnValue = await instance.init(result); + if (isHeadAndContent(returnValue) && returnValue.head) { + result._metadata.extraHead.push(returnValue.head); + } + } catch (error) { + // Stop on first error + throw error; + } + } + + queue.nodes.push(queueNode); + continue; + } + + // Handle RenderInstance (has a .render() method) + if (isRenderInstance(node)) { + // We'll need to render this to get its output + // For now, treat it as a component-like node + const queueNode = pool.acquire('component') as ComponentNode; + queueNode.instance = node as any; + queue.nodes.push(queueNode); + continue; + } + + // Handle iterables (but not strings) + if (typeof node === 'object' && Symbol.iterator in node) { + const items = Array.from(node); + // Push items onto stack in forward order - stack pop() will reverse, then final reverse() corrects it + for (const iterItem of items) { + stack.push({ node: iterItem, parent, metadata: item.metadata }); + } + continue; + } + + // Handle async iterables + if (typeof node === 'object' && Symbol.asyncIterator in node) { + try { + const items = []; + for await (const asyncItem of node) { + items.push(asyncItem); + } + // Push items onto stack in forward order - stack pop() will reverse, then final reverse() corrects it + for (const iterItem of items) { + stack.push({ node: iterItem, parent, metadata: item.metadata }); + } + } catch (error) { + // Stop on first error + throw error; + } + continue; + } + + // Handle Response objects + if (node instanceof Response) { + // Responses can't be rendered in the queue, they need to bubble up + // We'll create a special node for this + const queueNode = pool.acquire('html-string', '') as HtmlStringNode; + queueNode.html = ''; + queue.nodes.push(queueNode); + continue; + } + + // Fallback: convert to string - use content-aware caching + // Check if it's already marked as safe HTML (HTMLString) + if (isHTMLString(node)) { + const html = String(node); + const queueNode = pool.acquire('html-string', html) as HtmlStringNode; + queueNode.html = html; + queue.nodes.push(queueNode); + } else { + const str = String(node); + const queueNode = pool.acquire('text', str) as TextNode; + queueNode.content = str; + queue.nodes.push(queueNode); + } + } + + // Reverse the queue to get correct rendering order + queue.nodes.reverse(); + + return queue; +} diff --git a/packages/astro/src/runtime/server/render/queue/jsx-builder.ts b/packages/astro/src/runtime/server/render/queue/jsx-builder.ts new file mode 100644 index 000000000000..be862a459acb --- /dev/null +++ b/packages/astro/src/runtime/server/render/queue/jsx-builder.ts @@ -0,0 +1,271 @@ +import type { SSRResult } from '../../../../types/public/internal.js'; +import type { AstroVNode } from '../../../../jsx-runtime/index.js'; +import { isVNode } from '../../../../jsx-runtime/index.js'; +import type { + RenderQueue, + StackItem, + TextNode, + HtmlStringNode, + QueueNode, + ComponentNode, +} from './types.js'; +import type { NodePool } from './pool.js'; +import { HTMLString, markHTMLString, spreadAttributes, voidElementNames } from '../../index.js'; +import { isAstroComponentFactory } from '../astro/factory.js'; +import { createAstroComponentInstance } from '../astro/instance.js'; +import { renderJSX } from '../../jsx.js'; +import type { HTMLStringCache } from '../../html-string-cache.js'; + +const ClientOnlyPlaceholder = 'astro-client-only'; + +// Stats for tracking JSX queue rendering usage +let jsxQueueStats = { + vnodeCount: 0, + elementCount: 0, + componentCount: 0, + hasLogged: false, +}; + +/** + * Get JSX queue rendering statistics + */ +export function getJSXQueueStats() { + return { ...jsxQueueStats }; +} + +/** + * Reset JSX queue rendering statistics + */ +export function resetJSXQueueStats() { + jsxQueueStats = { + vnodeCount: 0, + elementCount: 0, + componentCount: 0, + hasLogged: false, + }; +} + +/** + * Processes JSX VNodes and adds them to the render queue. + * Unlike renderJSX(), this doesn't build strings recursively - + * it pushes nodes directly to the queue for batching and memory efficiency. + * + * This function handles JSX created by astro:jsx (JSX in .astro files). + * It converts VNodes to queue nodes, enabling content-aware pooling and batching. + * + * @param vnode - JSX VNode to process + * @param result - SSR result context + * @param queue - Queue to append nodes to + * @param pool - Node pool for memory efficiency + * @param stack - Stack for depth-first traversal + * @param parent - Parent queue node (for tracking) + * @param metadata - Metadata passed through stack (props, slots, displayName) + */ +export function renderJSXToQueue( + vnode: any, + result: SSRResult, + queue: RenderQueue, + pool: NodePool, + stack: StackItem[], + parent: QueueNode | null, + metadata?: StackItem['metadata'], +): void { + // Track that JSX queue rendering is being used + jsxQueueStats.vnodeCount = jsxQueueStats.vnodeCount + 1; + + // Handle primitive types + if (vnode instanceof HTMLString) { + const html = vnode.toString(); + if (html.trim() === '') return; + const node = pool.acquire('html-string', html) as HtmlStringNode; + node.html = html; + queue.nodes.push(node); + return; + } + + if (typeof vnode === 'string') { + const node = pool.acquire('text', vnode) as TextNode; + node.content = vnode; + queue.nodes.push(node); + return; + } + + if (typeof vnode === 'number' || typeof vnode === 'boolean') { + const str = String(vnode); + const node = pool.acquire('text', str) as TextNode; + node.content = str; + queue.nodes.push(node); + return; + } + + // Handle null, undefined, false (but not 0) + if (vnode == null || vnode === false) { + return; + } + + // Handle arrays - push each item to stack + if (Array.isArray(vnode)) { + // Push in reverse order so they're popped in correct order + for (let i = vnode.length - 1; i >= 0; i = i - 1) { + stack.push({ node: vnode[i], parent, metadata }); + } + return; + } + + // Handle VNodes + if (!isVNode(vnode)) { + // Fallback: convert to string + const str = String(vnode); + const node = pool.acquire('text', str) as TextNode; + node.content = str; + queue.nodes.push(node); + return; + } + + // From here, we know it's an AstroVNode + handleVNode(vnode, result, queue, pool, stack, parent, metadata); +} + +function handleVNode( + vnode: AstroVNode, + result: SSRResult, + queue: RenderQueue, + pool: NodePool, + stack: StackItem[], + parent: QueueNode | null, + metadata?: StackItem['metadata'], +): void { + // Check for undefined component + if (!vnode.type) { + throw new Error( + `Unable to render ${result.pathname} because it contains an undefined Component!\nDid you forget to import the component or is it possible there is a typo?`, + ); + } + + // Fragment + if ((vnode.type as any) === Symbol.for('astro:fragment')) { + stack.push({ node: vnode.props?.children, parent, metadata }); + return; + } + + // Astro component factory + if (isAstroComponentFactory(vnode.type)) { + jsxQueueStats.componentCount = jsxQueueStats.componentCount + 1; + const factory = vnode.type; + let props: Record = {}; + let slots: Record = {}; + + for (const [key, value] of Object.entries(vnode.props ?? {})) { + if (key === 'children' || (value && typeof value === 'object' && value['$$slot'])) { + // Slots need to return rendered content + // We create a function that renders the JSX VNode to string + slots[key === 'children' ? 'default' : key] = () => renderJSX(result, value); + } else { + props[key] = value; + } + } + + // Create component instance + const displayName = metadata?.displayName || factory.name || 'Anonymous'; + const instance = createAstroComponentInstance(result, displayName, factory, props, slots); + + const queueNode = pool.acquire('component') as ComponentNode; + queueNode.instance = instance; + queue.nodes.push(queueNode); + return; + } + + // HTML element (string type like 'div', 'span') + if (typeof vnode.type === 'string' && vnode.type !== ClientOnlyPlaceholder) { + jsxQueueStats.elementCount = jsxQueueStats.elementCount + 1; + renderHTMLElement(vnode, result, queue, pool, stack, parent, metadata); + return; + } + + // Function component + if (typeof vnode.type === 'function') { + // Check for server:root + if (vnode.props?.['server:root']) { + const output = vnode.type(vnode.props ?? {}); + stack.push({ node: output, parent, metadata }); + return; + } + + // Regular function component - call it and process result + const output = vnode.type(vnode.props ?? {}); + stack.push({ node: output, parent, metadata }); + return; + } + + // Client-only placeholder or other component type + // Fall back to string rendering for complex cases + // This handles client:only and other edge cases + const output = renderJSX(result, vnode); + stack.push({ node: output, parent, metadata }); +} + +function renderHTMLElement( + vnode: AstroVNode, + _result: SSRResult, + queue: RenderQueue, + pool: NodePool, + stack: StackItem[], + parent: QueueNode | null, + metadata?: StackItem['metadata'], +): void { + const tag = vnode.type as string; + const { children, ...props } = vnode.props ?? {}; + + // Pre-render attributes and build complete HTML tag + const attrs = spreadAttributes(props); + + // Check if void element + const isVoidElement = (children == null || children === '') && voidElementNames.test(tag); + + if (isVoidElement) { + // Self-closing element as HTML-string (cached by content) + const html = `<${tag}${attrs}/>`; + const node = pool.acquire('html-string', html) as HtmlStringNode; + node.html = html; + queue.nodes.push(node); + return; + } + + // Non-void element: open tag + children + close tag + // Build opening tag as HTML-string (cached by content) + const openTag = `<${tag}${attrs}>`; + const openTagHtml = queue.htmlStringCache + ? queue.htmlStringCache.getOrCreate(openTag) + : markHTMLString(openTag); + stack.push({ node: openTagHtml, parent, metadata }); + + // Push children to stack if present + if (children != null && children !== '') { + // Prerender element children (handles +
+

This page tests script and style tags

+
+ + diff --git a/packages/astro/test/fixtures/queue-rendering/astro.config.mjs b/packages/astro/test/fixtures/queue-rendering/astro.config.mjs new file mode 100644 index 000000000000..f86e8a140cae --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/astro.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig } from 'astro/config'; +import react from '@astrojs/react'; + +export default defineConfig({ + integrations: [react()], + experimental: { + queuedRendering: { + enabled: true, + }, + }, +}); diff --git a/packages/astro/test/fixtures/queue-rendering/package.json b/packages/astro/test/fixtures/queue-rendering/package.json new file mode 100644 index 000000000000..557362d1c348 --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/queue-rendering", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/react": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + } +} diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/Counter.jsx b/packages/astro/test/fixtures/queue-rendering/src/components/Counter.jsx new file mode 100644 index 000000000000..27e86047c16c --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/components/Counter.jsx @@ -0,0 +1,12 @@ +import { useState } from 'react'; + +export default function Counter({ initialCount = 0 }) { + const [count, setCount] = useState(initialCount); + + return ( +
+

Count: {count}

+ +
+ ); +} diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/Nested.astro b/packages/astro/test/fixtures/queue-rendering/src/components/Nested.astro new file mode 100644 index 000000000000..2ef06af9e559 --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/components/Nested.astro @@ -0,0 +1,6 @@ +--- +const { level = 0 } = Astro.props; +--- +
+ Level {level} +
diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/Static.jsx b/packages/astro/test/fixtures/queue-rendering/src/components/Static.jsx new file mode 100644 index 000000000000..c4e5c176c6ec --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/components/Static.jsx @@ -0,0 +1,7 @@ +export default function Static({ message }) { + return ( +
+

Message: {message}

+
+ ); +} diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/WithHead.astro b/packages/astro/test/fixtures/queue-rendering/src/components/WithHead.astro new file mode 100644 index 000000000000..2309abcb3d7b --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/components/WithHead.astro @@ -0,0 +1,17 @@ +--- +const { title } = Astro.props; +--- +
+

{title}

+

This component adds content to the head

+
+ + + + diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/WithSlot.astro b/packages/astro/test/fixtures/queue-rendering/src/components/WithSlot.astro new file mode 100644 index 000000000000..9a22e7c043db --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/components/WithSlot.astro @@ -0,0 +1,9 @@ +--- +const { title } = Astro.props; +--- +
+

{title}

+
+ +
+
diff --git a/packages/astro/test/fixtures/queue-rendering/src/pages/client-components.astro b/packages/astro/test/fixtures/queue-rendering/src/pages/client-components.astro new file mode 100644 index 000000000000..5ca7ba363c46 --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/pages/client-components.astro @@ -0,0 +1,42 @@ +--- +import Counter from '../components/Counter.jsx'; +import Static from '../components/Static.jsx'; +--- + + + Client Components Test + + +

Client Components Test

+ +
+

client:load

+ +
+ +
+

client:idle

+ +
+ +
+

client:visible

+ +
+ +
+

client:media

+ +
+ +
+

client:only

+ +
+ +
+

No client directive (SSR only)

+ +
+ + diff --git a/packages/astro/test/fixtures/queue-rendering/src/pages/directives.astro b/packages/astro/test/fixtures/queue-rendering/src/pages/directives.astro new file mode 100644 index 000000000000..b68aaa292c29 --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/pages/directives.astro @@ -0,0 +1,33 @@ +--- +const htmlContent = 'Bold text from set:html'; +const textContent = 'This should be escaped'; +const inlineStyle = { color: 'red', fontSize: '20px' }; +--- + + + Astro Directives Test + + +

Directives Test

+ +
+

set:html

+
+
+ +
+

set:text

+
+
+ +
+

class:list

+
Class List Test
+
+ +
+

Inline Style Object

+
Styled Text
+
+ + diff --git a/packages/astro/test/fixtures/queue-rendering/src/pages/head-content.astro b/packages/astro/test/fixtures/queue-rendering/src/pages/head-content.astro new file mode 100644 index 000000000000..ec7ae11a8dce --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/pages/head-content.astro @@ -0,0 +1,31 @@ +--- +import WithHead from '../components/WithHead.astro'; +--- + + + Head Content Test + + + +

Head Content Test

+ +
+ +

Inline styles test

+
+ +
+ +
+ +
+ +
+ + diff --git a/packages/astro/test/fixtures/queue-rendering/src/pages/index.astro b/packages/astro/test/fixtures/queue-rendering/src/pages/index.astro new file mode 100644 index 000000000000..8200422133ab --- /dev/null +++ b/packages/astro/test/fixtures/queue-rendering/src/pages/index.astro @@ -0,0 +1,39 @@ +--- +import Nested from '../components/Nested.astro'; +import WithSlot from '../components/WithSlot.astro'; + +const items = ['First', 'Second', 'Third']; +--- + + + Queue Rendering Test + + +

Queue Rendering Test

+ +
+

Simple text rendering

+

Number: {42}

+

Boolean: {true}

+
+ +
+
    + {items.map(item =>
  • {item}
  • )} +
+
+ +
+ + + +
+ +
+ +

Slot content here

+

Multiple paragraphs

+
+
+ + diff --git a/packages/astro/test/jsx-queue-rendering.test.js b/packages/astro/test/jsx-queue-rendering.test.js new file mode 100644 index 000000000000..1f821cda258c --- /dev/null +++ b/packages/astro/test/jsx-queue-rendering.test.js @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; + +describe('JSX Queue Rendering', () => { + describe('Output comparison', () => { + let fixtureQueue; + let fixtureString; + + before(async () => { + // Build with queue rendering enabled (includes JSX queue rendering) + fixtureQueue = await loadFixture({ + root: './fixtures/jsx-queue-rendering/', + outDir: './dist/queue', + experimental: { + queuedRendering: { + enabled: true, + }, + }, + }); + await fixtureQueue.build(); + + // Build with queue rendering disabled (uses traditional string-based rendering) + fixtureString = await loadFixture({ + root: './fixtures/jsx-queue-rendering/', + outDir: './dist/string', + experimental: { + queuedRendering: { + enabled: false, + }, + }, + }); + await fixtureString.build(); + }); + + it('simple.html should match between queue and string rendering', async () => { + const queueHtml = await fixtureQueue.readFile('/simple/index.html'); + const stringHtml = await fixtureString.readFile('/simple/index.html'); + + assert.equal(queueHtml, stringHtml, 'simple.html output should be identical'); + }); + + it('nested.html should match between queue and string rendering', async () => { + const queueHtml = await fixtureQueue.readFile('/nested/index.html'); + const stringHtml = await fixtureString.readFile('/nested/index.html'); + + assert.equal(queueHtml, stringHtml, 'nested.html output should be identical'); + }); + + it('special-elements.html should match between queue and string rendering', async () => { + const queueHtml = await fixtureQueue.readFile('/special-elements/index.html'); + const stringHtml = await fixtureString.readFile('/special-elements/index.html'); + + assert.equal(queueHtml, stringHtml, 'special-elements.html output should be identical'); + }); + + it('mdx-simple.html should match between queue and string rendering', async () => { + const queueHtml = await fixtureQueue.readFile('/mdx-simple/index.html'); + const stringHtml = await fixtureString.readFile('/mdx-simple/index.html'); + + assert.equal(queueHtml, stringHtml, 'mdx-simple.html output should be identical'); + }); + + it('mdx-nested.html should match between queue and string rendering', async () => { + const queueHtml = await fixtureQueue.readFile('/mdx-nested/index.html'); + const stringHtml = await fixtureString.readFile('/mdx-nested/index.html'); + + assert.equal(queueHtml, stringHtml, 'mdx-nested.html output should be identical'); + }); + }); +}); diff --git a/packages/astro/test/queue-rendering.test.js b/packages/astro/test/queue-rendering.test.js new file mode 100644 index 000000000000..f375ceb81ecd --- /dev/null +++ b/packages/astro/test/queue-rendering.test.js @@ -0,0 +1,299 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; + +describe('Queue-based rendering - Static', () => { + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/queue-rendering/', + output: 'static', + }); + await fixture.build(); + }); + + describe('Basic rendering', () => { + it('should render index page successfully', async () => { + const html = await fixture.readFile('/index.html'); + + // Verify basic structure + assert.ok(html.includes('Queue Rendering Test')); + assert.ok(html.includes('

Queue Rendering Test

')); + }); + + it('should render simple text and primitives correctly', async () => { + const html = await fixture.readFile('/index.html'); + + assert.ok(html.includes('

Simple text rendering

')); + assert.ok(html.includes('

Number: 42

')); + assert.ok(html.includes('

Boolean: true

')); + }); + + it('should render arrays correctly', async () => { + const html = await fixture.readFile('/index.html'); + + assert.ok(html.includes('
  • First
  • ')); + assert.ok(html.includes('
  • Second
  • ')); + assert.ok(html.includes('
  • Third
  • ')); + + // Verify order + const firstPos = html.indexOf('
  • First
  • '); + const secondPos = html.indexOf('
  • Second
  • '); + const thirdPos = html.indexOf('
  • Third
  • '); + + assert.ok(firstPos < secondPos); + assert.ok(secondPos < thirdPos); + }); + + it('should render multiple component instances correctly', async () => { + const html = await fixture.readFile('/index.html'); + + assert.ok(html.includes('data-level="0"')); + assert.ok(html.includes('data-level="1"')); + assert.ok(html.includes('data-level="2"')); + + assert.ok(html.includes('Level 0')); + assert.ok(html.includes('Level 1')); + assert.ok(html.includes('Level 2')); + }); + + it('should render components with slots correctly', async () => { + const html = await fixture.readFile('/index.html'); + + assert.ok(html.includes('class="with-slot"')); + assert.ok(html.includes('

    Test Title

    ')); + assert.ok(html.includes('class="slot-content"')); + assert.ok(html.includes('

    Slot content here

    ')); + assert.ok(html.includes('

    Multiple paragraphs

    ')); + }); + }); + + describe('Astro directives', () => { + it('should handle set:html directive', async () => { + const html = await fixture.readFile('/directives/index.html'); + + // set:html should render raw HTML + assert.ok(html.includes('Bold text from set:html')); + }); + + it('should handle set:text directive', async () => { + const html = await fixture.readFile('/directives/index.html'); + + // set:text should escape HTML + assert.ok(html.includes('<em>This should be escaped</em>')); + }); + + it('should handle class:list directive', async () => { + const html = await fixture.readFile('/directives/index.html'); + + // class:list should merge classes correctly + assert.ok(html.includes('class="foo bar baz"')); + }); + + it('should handle inline style objects', async () => { + const html = await fixture.readFile('/directives/index.html'); + + // Style object should be converted to inline CSS + assert.ok(html.includes('color:red') || html.includes('color: red')); + assert.ok(html.includes('font-size:20px') || html.includes('font-size: 20px')); + }); + }); + + describe('Client components', () => { + it('should render client:load components', async () => { + const html = await fixture.readFile('/client-components/index.html'); + + // Should include the component HTML + assert.ok(html.includes('class="counter"')); + // React adds HTML comments, so check for the number separately + assert.ok(html.includes('>5<') || html.includes('5')); + + // Should include hydration script + assert.ok(html.includes('astro-island')); + assert.ok(html.includes('client:load')); + }); + + it('should render client:idle components', async () => { + const html = await fixture.readFile('/client-components/index.html'); + + assert.ok(html.includes('>10<') || html.includes('10')); + assert.ok(html.includes('client:idle')); + }); + + it('should render client:visible components', async () => { + const html = await fixture.readFile('/client-components/index.html'); + + assert.ok(html.includes('>15<') || html.includes('15')); + assert.ok(html.includes('client:visible')); + }); + + it('should render client:media components', async () => { + const html = await fixture.readFile('/client-components/index.html'); + + assert.ok(html.includes('>20<') || html.includes('20')); + assert.ok(html.includes('client:media')); + }); + + it('should render client:only components', async () => { + const html = await fixture.readFile('/client-components/index.html'); + + // client:only should not render on server + // The component placeholder should exist but not the SSR content + assert.ok(html.includes('client:only')); + }); + + it('should render static components without hydration', async () => { + const html = await fixture.readFile('/client-components/index.html'); + + // Static component should render but not have hydration + assert.ok(html.includes('Server-side only')); + assert.ok(html.includes('class="static-component"')); + }); + }); + + describe('Head content', () => { + it('should include inline styles in head', async () => { + const html = await fixture.readFile('/head-content/index.html'); + + // Inline styles should be hoisted to head or remain inline + assert.ok(html.includes('.inline-test')); + assert.ok(html.includes('color: green') || html.includes('color:green')); + }); + + it('should include component styles in head', async () => { + const html = await fixture.readFile('/head-content/index.html'); + + // Component styles should be in head + assert.ok(html.includes('.with-head')); + assert.ok(html.includes('border: 1px solid blue') || html.includes('border:1px solid blue')); + }); + + it('should include component scripts', async () => { + const html = await fixture.readFile('/head-content/index.html'); + + // Component scripts should be included + assert.ok(html.includes('WithHead script loaded')); + }); + + it('should include inline scripts', async () => { + const html = await fixture.readFile('/head-content/index.html'); + + // Inline scripts with is:inline should be included + assert.ok(html.includes('Inline script executed')); + }); + }); +}); + +describe('Queue-based rendering - SSR', () => { + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + /** @type {import('./test-utils.js').App} */ + let app; + + before(async () => { + // Note: In SSR mode (output: 'server'), pooling is automatically disabled + // because AppPipeline sets disablePooling: true in the render context. + // This is correct behavior since pooling provides no benefit in SSR + // where each request is independent. + fixture = await loadFixture({ + root: './fixtures/queue-rendering/', + output: 'server', + adapter: await import('./test-adapter.js').then((mod) => mod.default()), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should render SSR page with queue rendering', async () => { + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + + assert.ok(html.includes('Queue Rendering Test')); + assert.ok(html.includes('

    Queue Rendering Test

    ')); + }); + + it('should render directives page in SSR', async () => { + const request = new Request('http://example.com/directives'); + const response = await app.render(request); + const html = await response.text(); + + // set:html should render raw HTML + assert.ok(html.includes('Bold text from set:html')); + + // set:text should escape HTML + assert.ok(html.includes('<em>This should be escaped</em>')); + + // class:list should merge classes + assert.ok(html.includes('class="foo bar baz"')); + }); + + it('should render client components in SSR', async () => { + const request = new Request('http://example.com/client-components'); + const response = await app.render(request); + const html = await response.text(); + + // Should include the component HTML with SSR content + assert.ok(html.includes('class="counter"')); + // React adds HTML comments, so check for the number separately + assert.ok(html.includes('>5<') || html.includes('5')); + + // Should include hydration islands + assert.ok(html.includes('astro-island')); + assert.ok(html.includes('client:load')); + }); + + it('should render head content in SSR', async () => { + const request = new Request('http://example.com/head-content'); + const response = await app.render(request); + const html = await response.text(); + + // Component styles should be in head + assert.ok(html.includes('.with-head')); + + // Inline scripts should be included + assert.ok(html.includes('Inline script executed')); + }); +}); + +describe('Queue-based rendering - Configuration', () => { + it('should support custom pool size configuration', async () => { + const fixture = await loadFixture({ + root: './fixtures/queue-rendering/', + output: 'static', + experimental: { + queuedRendering: { + poolSize: 500, + }, + }, + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + + // Verify basic rendering still works with custom pool size + assert.ok(html.includes('

    Queue Rendering Test

    ')); + assert.ok(html.includes('

    Simple text rendering

    ')); + }); + + it('should support object configuration', async () => { + const fixture = await loadFixture({ + root: './fixtures/queue-rendering/', + output: 'static', + experimental: { + queuedRendering: { + enabled: true, + }, + }, + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + + // Verify rendering works with boolean config + assert.ok(html.includes('

    Queue Rendering Test

    ')); + assert.ok(html.includes('

    Simple text rendering

    ')); + }); +}); diff --git a/packages/astro/test/units/app/test-helpers.js b/packages/astro/test/units/app/test-helpers.js index d76a79d47b6a..1a7cb3c89997 100644 --- a/packages/astro/test/units/app/test-helpers.js +++ b/packages/astro/test/units/app/test-helpers.js @@ -51,6 +51,9 @@ export function createManifest({ routes, pageMap, base = '/', trailingSlash = 'i }, internalFetchHeaders: undefined, logLevel: /** @type {'silent'} */ ('silent'), + experimentalQueuedRendering: { + enabled: false, + }, }; } diff --git a/packages/astro/test/units/render/queue-batching.test.js b/packages/astro/test/units/render/queue-batching.test.js new file mode 100644 index 000000000000..db56cf242198 --- /dev/null +++ b/packages/astro/test/units/render/queue-batching.test.js @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { buildRenderQueue } from '../../../dist/runtime/server/render/queue/builder.js'; +import { renderQueue } from '../../../dist/runtime/server/render/queue/renderer.js'; +import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; +import { markHTMLString } from '../../../dist/runtime/server/index.js'; + +// Mock SSRResult for testing +function createMockResult() { + return { + _metadata: { + hasHydrationScript: false, + hasRenderedHead: false, + hasDirectives: new Set(), + headInTree: false, + extraHead: [], + propagators: new Set(), + }, + styles: new Set(), + scripts: new Set(), + links: new Set(), + }; +} + +// Create a NodePool for testing +function createMockPool() { + return new NodePool(1000); +} + +describe('Queue batching optimization', () => { + it('should batch consecutive text nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const items = ['Hello', ' ', 'world', '!']; + + const queue = await buildRenderQueue(items, result, pool); + + // All text nodes should be in the queue + assert.equal(queue.nodes.length, 4); + assert.equal( + queue.nodes.every((n) => n.type === 'text'), + true, + ); + + // When rendered, they should be batched into one write + let writeCount = 0; + let output = ''; + const destination = { + write(chunk) { + writeCount++; + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + + assert.equal(output, 'Hello world!'); + assert.equal(writeCount, 1); // All 4 nodes batched into 1 write! + }); + + it('should batch consecutive html-string nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + + const items = [markHTMLString('
    '), markHTMLString('content'), markHTMLString('
    ')]; + + const queue = await buildRenderQueue(items, result, pool); + + let writeCount = 0; + let output = ''; + const destination = { + write(chunk) { + writeCount++; + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + + // Should batch into single write + assert.equal(writeCount, 1, 'Should batch consecutive html-string nodes'); + assert.equal(output, '
    content
    '); + }); + + it('should NOT batch across component boundaries', async () => { + const result = createMockResult(); + const pool = createMockPool(); + + // Create a simple component + const componentInstance = { + render(dest) { + dest.write('

    Component

    '); + }, + }; + + const items = ['before', componentInstance, 'after']; + + const queue = await buildRenderQueue(items, result, pool); + + let writeCount = 0; + let output = ''; + const destination = { + write(chunk) { + writeCount++; + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + + // Should have 3 writes: batched 'before', component output, batched 'after' + assert.equal(writeCount, 3, 'Should NOT batch across component boundaries'); + assert.equal(output, 'before

    Component

    after'); + }); + + it('should demonstrate performance improvement with large arrays', async () => { + const result = createMockResult(); + const pool = createMockPool(); + + // Create a large array of text items (simulating a list) + const items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`); + + const queue = await buildRenderQueue(items, result, pool); + + assert.equal(queue.nodes.length, 1000); + + let writeCount = 0; + const destination = { + write() { + writeCount++; + }, + }; + + await renderQueue(queue, destination); + + // With batching: 1 write (all text nodes batched together) + // Without batching: 1000 writes (one per node) + assert.equal(writeCount, 1, 'Should batch 1000 text nodes into 1 write (99.9% reduction!)'); + }); + + it('should batch mixed text and html-string nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + + const items = [ + 'Text 1', + markHTMLString('Bold'), + 'Text 2', + markHTMLString('Italic'), + ]; + + const queue = await buildRenderQueue(items, result, pool); + + let writeCount = 0; + let output = ''; + const destination = { + write(chunk) { + writeCount++; + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + + // All should be batched since they're all batchable types + assert.equal(writeCount, 1); + assert.equal(output, 'Text 1BoldText 2Italic'); + }); +}); diff --git a/packages/astro/test/units/render/queue-pool-content-cache.test.js b/packages/astro/test/units/render/queue-pool-content-cache.test.js new file mode 100644 index 000000000000..e68aef174d51 --- /dev/null +++ b/packages/astro/test/units/render/queue-pool-content-cache.test.js @@ -0,0 +1,178 @@ +import { describe, it } from 'node:test'; +import { strictEqual, notStrictEqual } from 'node:assert'; +import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; + +describe('NodePool - Content-Aware Caching', () => { + it('should cache text nodes by content', () => { + const pool = new NodePool(1000, true, true); // Enable content cache + + // First acquisition - cache miss + const node1 = pool.acquire('text', 'Hello'); + strictEqual(node1.type, 'text'); + strictEqual(node1.content, 'Hello'); + + // Second acquisition - should be cache hit + const node2 = pool.acquire('text', 'Hello'); + strictEqual(node2.type, 'text'); + strictEqual(node2.content, 'Hello'); + + // Should be different object instances (cloned) + notStrictEqual(node1, node2); + + // Verify stats + const stats = pool.getStats(); + strictEqual(stats.contentCacheHit, 1); + strictEqual(stats.contentCacheMiss, 1); + }); + + it('should cache html-string nodes by content', () => { + const pool = new NodePool(1000, true, true); + + // First acquisition + const node1 = pool.acquire('html-string', '
    Test
    '); + strictEqual(node1.type, 'html-string'); + strictEqual(node1.html, '
    Test
    '); + + // Second acquisition - cache hit + const node2 = pool.acquire('html-string', '
    Test
    '); + strictEqual(node2.type, 'html-string'); + strictEqual(node2.html, '
    Test
    '); + + // Different instances + notStrictEqual(node1, node2); + + const stats = pool.getStats(); + strictEqual(stats.contentCacheHit, 1); + strictEqual(stats.contentCacheMiss, 1); + }); + + it('should differentiate between text and html-string with same content', () => { + const pool = new NodePool(1000, true, true); + + // Use custom content not in COMMON_HTML_PATTERNS + const textNode = pool.acquire('text', ''); + const htmlNode = pool.acquire('html-string', ''); + + strictEqual(textNode.type, 'text'); + strictEqual(textNode.content, ''); + strictEqual(htmlNode.type, 'html-string'); + strictEqual(htmlNode.html, ''); + + // Both should be cache misses (different types) + const stats = pool.getStats(); + strictEqual(stats.contentCacheHit, 0); + strictEqual(stats.contentCacheMiss, 2); + }); + + it('should allow modification of cloned nodes without affecting cache', () => { + const pool = new NodePool(1000, true, true); + + // Get first node + const node1 = pool.acquire('text', 'Shared'); + node1.parent = { type: 'element' }; + node1.position = 5; + node1.originalValue = 'original1'; + + // Get second node - should not have modifications from node1 + const node2 = pool.acquire('text', 'Shared'); + strictEqual(node2.parent, undefined); + strictEqual(node2.position, undefined); + strictEqual(node2.originalValue, undefined); + strictEqual(node2.content, 'Shared'); // Content preserved + + // Modify node2 + node2.parent = { type: 'fragment' }; + node2.position = 10; + + // Get third node - should not have modifications from node2 + const node3 = pool.acquire('text', 'Shared'); + strictEqual(node3.parent, undefined); + strictEqual(node3.position, undefined); + strictEqual(node3.content, 'Shared'); + }); + + it('should handle empty string content', () => { + const pool = new NodePool(1000, true, true); + + const node1 = pool.acquire('text', ''); + const node2 = pool.acquire('text', ''); + + strictEqual(node1.content, ''); + strictEqual(node2.content, ''); + notStrictEqual(node1, node2); + + const stats = pool.getStats(); + strictEqual(stats.contentCacheHit, 1); + }); + + it('should work when content caching is disabled', () => { + const pool = new NodePool(1000, true, false); // Disable content cache + + pool.acquire('text', 'Hello'); + pool.acquire('text', 'Hello'); + + // Should use standard pooling (not content cache) + const stats = pool.getStats(); + strictEqual(stats.contentCacheHit, 0); + strictEqual(stats.contentCacheMiss, 0); + }); + + it('should not cache component or instruction nodes', () => { + const pool = new NodePool(1000, true, true); + + // These types don't support content caching + pool.acquire('component'); + pool.acquire('component'); + pool.acquire('instruction'); + pool.acquire('instruction'); + + // Should use standard pooling (no content cache) + const stats = pool.getStats(); + strictEqual(stats.contentCacheHit, 0); + strictEqual(stats.contentCacheMiss, 0); + // All 4 nodes created new (pool starts empty) + strictEqual(stats.acquireNew, 4); + }); + + it('should handle large content strings', () => { + const pool = new NodePool(1000, true, true); + + const largeContent = 'x'.repeat(10000); + const node1 = pool.acquire('text', largeContent); + const node2 = pool.acquire('text', largeContent); + + strictEqual(node1.content, largeContent); + strictEqual(node2.content, largeContent); + notStrictEqual(node1, node2); + + const stats = pool.getStats(); + strictEqual(stats.contentCacheHit, 1); + }); + + it('should cache common HTML patterns', () => { + const pool = new NodePool(1000, true, true); + + // Use custom patterns not in COMMON_HTML_PATTERNS + const patterns = ['
    ', '', '', '
    ', '']; + + // First pass - all cache misses + for (const pattern of patterns) { + const node = pool.acquire('html-string', pattern); + strictEqual(node.html, pattern); + } + + let stats = pool.getStats(); + strictEqual(stats.contentCacheMiss, 5); + strictEqual(stats.contentCacheHit, 0); + + // Second pass - all cache hits + for (const pattern of patterns) { + const node = pool.acquire('html-string', pattern); + strictEqual(node.html, pattern); + } + + stats = pool.getStats(); + strictEqual(stats.contentCacheMiss, 5); + strictEqual(stats.contentCacheHit, 5); + }); +}); diff --git a/packages/astro/test/units/render/queue-pool-prewarming.test.js b/packages/astro/test/units/render/queue-pool-prewarming.test.js new file mode 100644 index 000000000000..211c11c955e6 --- /dev/null +++ b/packages/astro/test/units/render/queue-pool-prewarming.test.js @@ -0,0 +1,165 @@ +import { describe, it } from 'node:test'; +import { strictEqual, ok } from 'node:assert'; +import { NodePool, COMMON_HTML_PATTERNS } from '../../../dist/runtime/server/render/queue/pool.js'; + +describe('NodePool - Cache Pre-warming', () => { + it('should warm cache with provided patterns', () => { + const pool = new NodePool(1000, true, true); + + const patterns = [ + { type: 'text', content: 'Hello' }, + { type: 'html-string', content: '
    ' }, + { type: 'html-string', content: '
    ' }, + ]; + + pool.warmCache(patterns); + + // First acquisition should be cache hit (already warmed) + pool.acquire('text', 'Hello'); + pool.acquire('html-string', '
    '); + pool.acquire('html-string', '
    '); + + const stats = pool.getStats(); + strictEqual(stats.contentCacheHit, 3, 'All 3 patterns should be cache hits'); + strictEqual(stats.contentCacheMiss, 0, 'No cache misses expected'); + }); + + it('should not warm cache when content caching is disabled', () => { + const pool = new NodePool(1000, true, false); // Disable content cache + + const patterns = [{ type: 'text', content: 'Hello' }]; + + pool.warmCache(patterns); // Should be no-op + + pool.acquire('text', 'Hello'); + const stats = pool.getStats(); + + strictEqual(stats.contentCacheHit, 0, 'Content caching disabled, no hits'); + }); + + it('should not duplicate patterns already in cache', () => { + const pool = new NodePool(1000, true, true); + + // Acquire first to populate cache + pool.acquire('text', 'Test'); + + // Try to warm with same pattern + pool.warmCache([{ type: 'text', content: 'Test' }]); + + // Acquire again - should still be just 1 cache hit total (not 2) + pool.acquire('text', 'Test'); + + const stats = pool.getStats(); + strictEqual(stats.contentCacheHit, 1, 'Should not duplicate existing cache entries'); + strictEqual(stats.contentCacheMiss, 1, 'First acquire was cache miss'); + }); + + it('should include common HTML patterns', () => { + ok(COMMON_HTML_PATTERNS.length > 0, 'COMMON_HTML_PATTERNS should not be empty'); + + // Check for some expected patterns + const patterns = COMMON_HTML_PATTERNS.map((p) => p.content); + ok(patterns.includes('
    '), 'Should include
    '); + ok(patterns.includes('
    '), 'Should include
    '); + ok(patterns.includes('
    '), 'Should include
    '); + ok(patterns.includes(' '), 'Should include space'); + ok(patterns.includes('\n'), 'Should include newline'); + }); + + it.skip('should pre-warm global pool on initialization', () => { + // Global pool should already be warmed + // Try to acquire common patterns - should all be cache hits + // TODO: This test expects a globalNodePool export that doesn't exist + const stats1 = globalNodePool.getStats(); + const initialHits = stats1.contentCacheHit; + + globalNodePool.acquire('html-string', '
    '); + globalNodePool.acquire('html-string', '
    '); + globalNodePool.acquire('html-string', '
    '); + globalNodePool.acquire('text', ' '); + + const stats2 = globalNodePool.getStats(); + strictEqual( + stats2.contentCacheHit - initialHits, + 4, + 'All common patterns should be cache hits', + ); + }); + + it('should improve hit rate with pre-warming', () => { + // Both pools start with COMMON_HTML_PATTERNS pre-warmed (automatic in constructor) + // We test the benefit of warming ADDITIONAL custom patterns + const poolWithoutCustomWarm = new NodePool(1000, true, true); + const poolWithCustomWarm = new NodePool(1000, true, true); + + // Use custom patterns NOT in COMMON_HTML_PATTERNS + const customPatterns = [ + { type: 'html-string', content: '' }, + { type: 'html-string', content: '' }, + ]; + + // Pre-warm one pool with ADDITIONAL custom patterns + poolWithCustomWarm.warmCache(customPatterns); + + // Simulate page rendering with repeated custom patterns + for (let i = 0; i < 100; i++) { + poolWithoutCustomWarm.acquire('html-string', ''); + poolWithoutCustomWarm.acquire('html-string', ''); + poolWithCustomWarm.acquire('html-string', ''); + poolWithCustomWarm.acquire('html-string', ''); + } + + const stats1 = poolWithoutCustomWarm.getStats(); + const stats2 = poolWithCustomWarm.getStats(); + + // Without custom warming: first two custom patterns are misses (not in COMMON_HTML_PATTERNS) + strictEqual(stats1.contentCacheMiss, 2, 'Two custom patterns = 2 misses'); + strictEqual(stats1.contentCacheHit, 198, '198 hits after initial misses'); + + // With custom warming: all are hits (custom patterns were pre-warmed) + strictEqual(stats2.contentCacheMiss, 0, 'Custom pre-warmed = no misses'); + strictEqual(stats2.contentCacheHit, 200, 'All 200 are hits with custom pre-warming'); + }); + + it('should warm cache with void elements', () => { + const pool = new NodePool(1000, true, true); + + pool.warmCache([ + { type: 'html-string', content: '
    ' }, + { type: 'html-string', content: '
    ' }, + { type: 'html-string', content: '
    ' }, + { type: 'html-string', content: '
    ' }, + ]); + + // All should be cache hits + pool.acquire('html-string', '
    '); + pool.acquire('html-string', '
    '); + pool.acquire('html-string', '
    '); + pool.acquire('html-string', '
    '); + + const stats = pool.getStats(); + strictEqual(stats.contentCacheHit, 4); + strictEqual(stats.contentCacheMiss, 0); + }); + + it('should warm cache with text patterns', () => { + const pool = new NodePool(1000, true, true); + + pool.warmCache([ + { type: 'text', content: 'Read more' }, + { type: 'text', content: 'Continue reading' }, + { type: 'text', content: ' ' }, + { type: 'text', content: '\n' }, + ]); + + // All should be cache hits + pool.acquire('text', 'Read more'); + pool.acquire('text', 'Continue reading'); + pool.acquire('text', ' '); + pool.acquire('text', '\n'); + + const stats = pool.getStats(); + strictEqual(stats.contentCacheHit, 4); + strictEqual(stats.contentCacheMiss, 0); + }); +}); diff --git a/packages/astro/test/units/render/queue-pool.test.js b/packages/astro/test/units/render/queue-pool.test.js new file mode 100644 index 000000000000..d811a7103dd1 --- /dev/null +++ b/packages/astro/test/units/render/queue-pool.test.js @@ -0,0 +1,138 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; +import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; + +describe('NodePool', () => { + it('should acquire a new node when pool is empty', () => { + const pool = new NodePool(); + const node = pool.acquire('text'); + + strictEqual(node.type, 'text'); + strictEqual(node.content, ''); // Default value for new TextNode + }); + + it('should reuse released nodes', () => { + const pool = new NodePool(); + + // Acquire and set up a node + const node1 = pool.acquire('text'); + node1.content = 'Hello'; + + // Release it back to the pool + pool.release(node1); + strictEqual(pool.size(), 1); + + // Acquire another node - with discriminated union, we create a fresh node + const node2 = pool.acquire('html-string'); + strictEqual(node2.type, 'html-string'); // Type is html-string + strictEqual(node2.html, ''); // Default value for new HtmlStringNode + + // Pool size should decrease (node was consumed from pool) + strictEqual(pool.size(), 0); + }); + + it('should respect maxSize limit', () => { + const pool = new NodePool(2); // Max size of 2 + + const node1 = pool.acquire('text'); + const node2 = pool.acquire('text'); + const node3 = pool.acquire('text'); + + pool.release(node1); + pool.release(node2); + pool.release(node3); // Should be discarded + + strictEqual(pool.size(), 2); // Only 2 nodes retained + }); + + it('should clear the pool', () => { + const pool = new NodePool(); + + // Acquire nodes first, then release them all at once + const node1 = pool.acquire('text'); + const node2 = pool.acquire('text'); + const node3 = pool.acquire('text'); + + pool.release(node1); + pool.release(node2); + pool.release(node3); + + strictEqual(pool.size(), 3); + + pool.clear(); + strictEqual(pool.size(), 0); + }); + + it('should release all nodes in an array', () => { + const pool = new NodePool(); + + const nodes = [pool.acquire('text'), pool.acquire('html-string'), pool.acquire('component')]; + + pool.releaseAll(nodes); + strictEqual(pool.size(), 3); + }); + + it('should properly create nodes with correct discriminated union types', () => { + const pool = new NodePool(); + + // Acquire different node types + const textNode = pool.acquire('text'); + const htmlNode = pool.acquire('html-string'); + const componentNode = pool.acquire('component'); + const instructionNode = pool.acquire('instruction'); + + // Each node should have only its relevant fields (discriminated union) + strictEqual(textNode.type, 'text'); + strictEqual(textNode.content, ''); + + strictEqual(htmlNode.type, 'html-string'); + strictEqual(htmlNode.html, ''); + + strictEqual(componentNode.type, 'component'); + strictEqual(componentNode.instance, undefined); + + strictEqual(instructionNode.type, 'instruction'); + strictEqual(instructionNode.instruction, undefined); + }); + + it('should handle multiple acquire/release cycles', () => { + const pool = new NodePool(10); + + // First cycle + const batch1 = []; + for (let i = 0; i < 5; i++) { + batch1.push(pool.acquire('text')); + } + pool.releaseAll(batch1); + strictEqual(pool.size(), 5); + + // Second cycle - should reuse from pool + const batch2 = []; + for (let i = 0; i < 3; i++) { + batch2.push(pool.acquire('html-string')); + } + strictEqual(pool.size(), 2); // 5 - 3 = 2 remaining + + pool.releaseAll(batch2); + strictEqual(pool.size(), 5); // 2 + 3 = 5 + }); + + it('should work correctly with default maxSize', () => { + const pool = new NodePool(); // Default maxSize = 1000 + + // Create and release many nodes + const nodes = []; + for (let i = 0; i < 100; i++) { + nodes.push(pool.acquire('text')); + } + + pool.releaseAll(nodes); + strictEqual(pool.size(), 100); + + // All should be reusable + for (let i = 0; i < 100; i++) { + pool.acquire('text'); + } + strictEqual(pool.size(), 0); // All reused + }); +}); diff --git a/packages/astro/test/units/render/queue-rendering.test.js b/packages/astro/test/units/render/queue-rendering.test.js new file mode 100644 index 000000000000..4a6ac17d62f1 --- /dev/null +++ b/packages/astro/test/units/render/queue-rendering.test.js @@ -0,0 +1,263 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { buildRenderQueue } from '../../../dist/runtime/server/render/queue/builder.js'; +import { renderQueue } from '../../../dist/runtime/server/render/queue/renderer.js'; +import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; + +/** + * Tests for the queue-based rendering engine + * These are unit tests for the core queue building and rendering logic + */ +describe('Queue-based rendering engine', () => { + // Create a minimal SSRResult mock for testing + function createMockResult() { + return { + _metadata: { + hasHydrationScript: false, + rendererSpecificHydrationScripts: new Set(), + hasRenderedHead: false, + renderedScripts: new Set(), + hasDirectives: new Set(), + hasRenderedServerIslandRuntime: false, + headInTree: false, + extraHead: [], + extraStyleHashes: [], + extraScriptHashes: [], + propagators: new Set(), + }, + styles: new Set(), + scripts: new Set(), + links: new Set(), + componentMetadata: new Map(), + cancelled: false, + compressHTML: false, + }; + } + + // Create a NodePool for testing + function createMockPool() { + return new NodePool(1000); + } + + describe('buildRenderQueue()', () => { + it('should handle simple text nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue('Hello, World!', result, pool); + + assert.ok(queue.nodes.length > 0); + assert.equal(queue.nodes[0].type, 'text'); + assert.equal(queue.nodes[0].content, 'Hello, World!'); + }); + + it('should handle numbers', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue(42, result, pool); + + assert.ok(queue.nodes.length > 0); + assert.equal(queue.nodes[0].type, 'text'); + assert.equal(queue.nodes[0].content, '42'); + }); + + it('should handle booleans', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue(true, result, pool); + + assert.ok(queue.nodes.length > 0); + assert.equal(queue.nodes[0].type, 'text'); + assert.equal(queue.nodes[0].content, 'true'); + }); + + it('should handle arrays', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue(['Hello', ' ', 'World'], result, pool); + + assert.equal(queue.nodes.length, 3); + assert.equal(queue.nodes[0].content, 'Hello'); + assert.equal(queue.nodes[1].content, ' '); + assert.equal(queue.nodes[2].content, 'World'); + }); + + it('should handle null and undefined (skip them)', async () => { + const result = createMockResult(); + const nullQueue = await buildRenderQueue(null, result); + const undefinedQueue = await buildRenderQueue(undefined, result); + + assert.equal(nullQueue.nodes.length, 0); + assert.equal(undefinedQueue.nodes.length, 0); + }); + + it('should skip false but render 0', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const falseQueue = await buildRenderQueue(false, result, pool); + const zeroQueue = await buildRenderQueue(0, result, pool); + + assert.equal(falseQueue.nodes.length, 0); + assert.equal(zeroQueue.nodes.length, 1); + assert.equal(zeroQueue.nodes[0].content, '0'); + }); + + it('should handle promises', async () => { + const result = createMockResult(); + const promise = Promise.resolve('Resolved value'); + const pool = createMockPool(); + const queue = await buildRenderQueue(promise, result, pool); + + assert.equal(queue.nodes.length, 1); + assert.equal(queue.nodes[0].content, 'Resolved value'); + }); + + it('should handle nested arrays', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue([['Nested', ' '], 'Array'], result, pool); + + assert.equal(queue.nodes.length, 3); + assert.equal(queue.nodes[0].content, 'Nested'); + assert.equal(queue.nodes[1].content, ' '); + assert.equal(queue.nodes[2].content, 'Array'); + }); + + it('should handle async iterables', async () => { + const result = createMockResult(); + + async function* asyncGen() { + yield 'First'; + yield 'Second'; + yield 'Third'; + } + + const pool = createMockPool(); + const queue = await buildRenderQueue(asyncGen(), result, pool); + + assert.equal(queue.nodes.length, 3); + assert.equal(queue.nodes[0].content, 'First'); + assert.equal(queue.nodes[1].content, 'Second'); + assert.equal(queue.nodes[2].content, 'Third'); + }); + + it('should track parent relationships', async () => { + const result = createMockResult(); + const nestedArray = [['child1', 'child2'], 'sibling']; + const pool = createMockPool(); + const queue = await buildRenderQueue(nestedArray, result, pool); + + // Verify correct node structure + assert.equal(queue.nodes.length, 3); + assert.equal(queue.nodes[0].content, 'child1'); + assert.equal(queue.nodes[1].content, 'child2'); + assert.equal(queue.nodes[2].content, 'sibling'); + }); + + it('should maintain correct rendering order', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue(['A', 'B', 'C'], result, pool); + + assert.equal(queue.nodes[0].content, 'A'); + assert.equal(queue.nodes[1].content, 'B'); + assert.equal(queue.nodes[2].content, 'C'); + }); + + it('should handle sync iterables (Set)', async () => { + const result = createMockResult(); + const set = new Set(['One', 'Two', 'Three']); + const pool = createMockPool(); + const queue = await buildRenderQueue(set, result, pool); + + assert.equal(queue.nodes.length, 3); + // Set iteration order is insertion order + const contents = queue.nodes.map((n) => n.content); + assert.ok(contents.includes('One')); + assert.ok(contents.includes('Two')); + assert.ok(contents.includes('Three')); + }); + }); + + describe('renderQueue()', () => { + it('should render simple text to string', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue('Test content', result, pool); + + let output = ''; + const destination = { + write(chunk) { + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + assert.ok(output.includes('Test content')); + }); + + it('should render array to concatenated string', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue(['Hello', ' ', 'World'], result, pool); + + let output = ''; + const destination = { + write(chunk) { + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + assert.equal(output, 'Hello World'); + }); + + it('should escape HTML in text nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const queue = await buildRenderQueue('', result, pool); + + let output = ''; + const destination = { + write(chunk) { + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + assert.ok(!output.includes('