diff --git a/.changeset/long-chefs-tie.md b/.changeset/long-chefs-tie.md new file mode 100644 index 000000000000..8ba3855c4c12 --- /dev/null +++ b/.changeset/long-chefs-tie.md @@ -0,0 +1,5 @@ +--- +'@astrojs/markdown-remark': patch +--- + +Fixes an issue where the use of the `Code` component would result in an unexpected error. diff --git a/.changeset/many-cars-hope.md b/.changeset/many-cars-hope.md new file mode 100644 index 000000000000..4c70d58fc5ce --- /dev/null +++ b/.changeset/many-cars-hope.md @@ -0,0 +1,27 @@ +--- +'astro': minor +--- + +Adds a new `middlewareMode` adapter feature to replace the previous `edgeMiddleware` option. + +This feature only impacts adapter authors. If your adapter supports `edgeMiddleware`, you should upgrade to the new `middlewareMode` option to specify the middleware mode for your adapter as soon as possible. The `edgeMiddleware` feature is deprecated and will be removed in a future major release. + +```diff +export default function createIntegration() { + return { + name: '@example/my-adapter', + hooks: { + 'astro:config:done': ({ setAdapter }) => { + setAdapter({ + name: '@example/my-adapter', + serverEntrypoint: '@example/my-adapter/server.js', + adapterFeatures: { +- edgeMiddleware: true ++ middlewareMode: 'edge' + } + }); + }, + }, + }; +} +``` diff --git a/.changeset/social-jeans-mix.md b/.changeset/social-jeans-mix.md new file mode 100644 index 000000000000..15ffb87144d9 --- /dev/null +++ b/.changeset/social-jeans-mix.md @@ -0,0 +1,16 @@ +--- +'@astrojs/netlify': minor +--- + +Adds new `middlewareMode` adapter feature and deprecates `edgeMiddleware` option + +The `edgeMiddleware` option is now deprecated and will be removed in a future major release, so users should transition to using the new `middlewareMode` feature as soon as possible. + +```diff +export default defineConfig({ + adapter: netlify({ +- edgeMiddleware: true ++ middlewareMode: 'edge' + }) +}) +``` diff --git a/.changeset/three-sheep-burn.md b/.changeset/three-sheep-burn.md new file mode 100644 index 000000000000..6f442eb712f2 --- /dev/null +++ b/.changeset/three-sheep-burn.md @@ -0,0 +1,16 @@ +--- +'@astrojs/vercel': minor +--- + +Adds new `middlewareMode` adapter feature and deprecates `edgeMiddleware` option + +The `edgeMiddleware` option is now deprecated and will be removed in a future release, so users should transition to using the new `middlewareMode` feature as soon as possible. + +```diff +export default defineConfig({ + adapter: vercel({ +- edgeMiddleware: true ++ middlewareMode: 'edge' + }) +}) +``` diff --git a/.changeset/yellow-cycles-cheer.md b/.changeset/yellow-cycles-cheer.md new file mode 100644 index 000000000000..85347db4625a --- /dev/null +++ b/.changeset/yellow-cycles-cheer.md @@ -0,0 +1,5 @@ +--- +'@astrojs/cloudflare': patch +'@astrojs/node': patch +--- +Refactors to use `middlewareMode` adapter feature (set to `classic`) \ No newline at end of file diff --git a/.changeset/young-cougars-mix.md b/.changeset/young-cougars-mix.md new file mode 100644 index 000000000000..7136b8c2a766 --- /dev/null +++ b/.changeset/young-cougars-mix.md @@ -0,0 +1,19 @@ +--- +'@astrojs/node': patch +'astro': patch +--- + +Adds a new `security.actionBodySizeLimit` option to configure the maximum size of Astro Actions request bodies. + +This lets you increase the default 1 MB limit when your actions need to accept larger payloads. For example, actions that handle file uploads or large JSON payloads can now opt in to a higher limit. + +If you do not set this option, Astro continues to enforce the 1 MB default to help prevent abuse. + +```js +// astro.config.mjs +export default defineConfig({ + security: { + actionBodySizeLimit: 10 * 1024 * 1024 // set to 10 MB + } +}) +``` diff --git a/packages/astro/src/actions/runtime/server.ts b/packages/astro/src/actions/runtime/server.ts index d015466121cb..5daeaf803dcc 100644 --- a/packages/astro/src/actions/runtime/server.ts +++ b/packages/astro/src/actions/runtime/server.ts @@ -195,9 +195,10 @@ export function getActionContext(context: APIContext): AstroActionContext { throw error; } + const bodySizeLimit = pipeline.manifest.actionBodySizeLimit; let input; try { - input = await parseRequestBody(context.request); + input = await parseRequestBody(context.request, bodySizeLimit); } catch (e) { if (e instanceof ActionError) { return { data: undefined, error: e }; @@ -253,24 +254,22 @@ function getCallerInfo(ctx: APIContext) { return undefined; } -const DEFAULT_ACTION_BODY_SIZE_LIMIT = 1024 * 1024; - -async function parseRequestBody(request: Request) { +async function parseRequestBody(request: Request, bodySizeLimit: number) { const contentType = request.headers.get('content-type'); const contentLengthHeader = request.headers.get('content-length'); const contentLength = contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : undefined; const hasContentLength = typeof contentLength === 'number' && Number.isFinite(contentLength); if (!contentType) return undefined; - if (hasContentLength && contentLength > DEFAULT_ACTION_BODY_SIZE_LIMIT) { + if (hasContentLength && contentLength > bodySizeLimit) { throw new ActionError({ code: 'CONTENT_TOO_LARGE', - message: `Request body exceeds ${DEFAULT_ACTION_BODY_SIZE_LIMIT} bytes`, + message: `Request body exceeds ${bodySizeLimit} bytes`, }); } if (hasContentType(contentType, formContentTypes)) { if (!hasContentLength) { - const body = await readRequestBodyWithLimit(request.clone(), DEFAULT_ACTION_BODY_SIZE_LIMIT); + const body = await readRequestBodyWithLimit(request.clone(), bodySizeLimit); const formRequest = new Request(request.url, { method: request.method, headers: request.headers, @@ -283,7 +282,7 @@ async function parseRequestBody(request: Request) { if (hasContentType(contentType, ['application/json'])) { if (contentLength === 0) return undefined; if (!hasContentLength) { - const body = await readRequestBodyWithLimit(request.clone(), DEFAULT_ACTION_BODY_SIZE_LIMIT); + const body = await readRequestBodyWithLimit(request.clone(), bodySizeLimit); if (body.byteLength === 0) return undefined; return JSON.parse(new TextDecoder().decode(body)); } diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index 6a887277d035..4612a1365d27 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -150,6 +150,7 @@ function createManifest( compressHTML: manifest?.compressHTML ?? ASTRO_CONFIG_DEFAULTS.compressHTML, assetsDir: manifest?.assetsDir ?? ASTRO_CONFIG_DEFAULTS.build.assets, serverLike: manifest?.serverLike ?? true, + middlewareMode: manifest?.middlewareMode ?? 'classic', assets: manifest?.assets ?? new Set(), assetsPrefix: manifest?.assetsPrefix ?? undefined, entryModules: manifest?.entryModules ?? {}, @@ -164,6 +165,7 @@ function createManifest( i18n: manifest?.i18n, checkOrigin: false, allowedDomains: manifest?.allowedDomains ?? [], + actionBodySizeLimit: 1024 * 1024, middleware: manifest?.middleware ?? middlewareInstance, key: createKey(), csp: manifest?.csp, @@ -267,6 +269,7 @@ type AstroContainerManifest = Pick< | 'csp' | 'allowedDomains' | 'serverLike' + | 'middlewareMode' | 'assetsDir' | 'image' | 'experimentalQueuedRendering' diff --git a/packages/astro/src/content/content-layer.ts b/packages/astro/src/content/content-layer.ts index c8827bcd2271..459bf43448ca 100644 --- a/packages/astro/src/content/content-layer.ts +++ b/packages/astro/src/content/content-layer.ts @@ -32,11 +32,12 @@ import { } from './utils.js'; import { createWatcherWrapper, type WrappedWatcher } from './watcher.js'; -interface ContentLayerOptions { +export interface ContentLayerOptions { store: MutableDataStore; settings: AstroSettings; logger: Logger; watcher?: FSWatcher; + contentConfigObserver?: ContentObservable; } type CollectionLoader = () => @@ -45,7 +46,7 @@ type CollectionLoader = () => | Record> | Promise>>; -class ContentLayer { +export class ContentLayer { #logger: Logger; #store: MutableDataStore; #settings: AstroSettings; @@ -54,16 +55,24 @@ class ContentLayer { #unsubscribe?: () => void; #markdownProcessor?: MarkdownProcessor; #generateDigest?: (data: Record | string) => string; + #contentConfigObserver: ContentObservable; #queue: PQueue; - constructor({ settings, logger, store, watcher }: ContentLayerOptions) { + constructor({ + settings, + logger, + store, + watcher, + contentConfigObserver = globalContentConfigObserver, + }: ContentLayerOptions) { // The default max listeners is 10, which can be exceeded when using a lot of loaders watcher?.setMaxListeners(50); this.#logger = logger; this.#store = store; this.#settings = settings; + this.#contentConfigObserver = contentConfigObserver; if (watcher) { this.#watcher = createWatcherWrapper(watcher); } @@ -82,7 +91,7 @@ class ContentLayer { */ watchContentConfig() { this.#unsubscribe?.(); - this.#unsubscribe = globalContentConfigObserver.subscribe(async (ctx) => { + this.#unsubscribe = this.#contentConfigObserver.subscribe(async (ctx) => { if (ctx.status === 'loaded' && ctx.config.digest !== this.#lastConfigDigest) { this.sync(); } @@ -172,13 +181,13 @@ class ContentLayer { } async #doSync(options: RefreshContentOptions) { - let contentConfig = globalContentConfigObserver.get(); + let contentConfig = this.#contentConfigObserver.get(); const logger = this.#logger.forkIntegrationLogger('content'); if (contentConfig?.status === 'loading') { contentConfig = await Promise.race>([ new Promise((resolve) => { - const unsub = globalContentConfigObserver.subscribe((ctx) => { + const unsub = this.#contentConfigObserver.subscribe((ctx) => { unsub(); resolve(ctx); }); @@ -230,9 +239,9 @@ class ContentLayer { this.#lastConfigDigest = currentConfigDigest; let shouldClear = false; - const previousConfigDigest = await this.#store.metaStore().get('content-config-digest'); - const previousAstroConfigDigest = await this.#store.metaStore().get('astro-config-digest'); - const previousAstroVersion = await this.#store.metaStore().get('astro-version'); + const previousConfigDigest = this.#store.metaStore().get('content-config-digest'); + const previousAstroConfigDigest = this.#store.metaStore().get('astro-config-digest'); + const previousAstroVersion = this.#store.metaStore().get('astro-version'); if (previousAstroConfigDigest && previousAstroConfigDigest !== astroConfigDigest) { logger.info('Astro config changed'); @@ -252,13 +261,13 @@ class ContentLayer { this.#store.clearAll(); } if (process.env.ASTRO_VERSION) { - await this.#store.metaStore().set('astro-version', process.env.ASTRO_VERSION); + this.#store.metaStore().set('astro-version', process.env.ASTRO_VERSION); } if (currentConfigDigest) { - await this.#store.metaStore().set('content-config-digest', currentConfigDigest); + this.#store.metaStore().set('content-config-digest', currentConfigDigest); } if (astroConfigDigest) { - await this.#store.metaStore().set('astro-config-digest', astroConfigDigest); + this.#store.metaStore().set('astro-config-digest', astroConfigDigest); } if (!options?.loaders?.length) { @@ -459,21 +468,3 @@ async function simpleLoader( export function getDataStoreFile(settings: AstroSettings, isDev: boolean) { return new URL(DATA_STORE_FILE, isDev ? settings.dotAstroDir : settings.config.cacheDir); } - -function contentLayerSingleton() { - let instance: ContentLayer | null = null; - return { - init: (options: ContentLayerOptions) => { - instance?.dispose(); - instance = new ContentLayer(options); - return instance; - }, - get: () => instance, - dispose: () => { - instance?.dispose(); - instance = null; - }, - }; -} - -export const globalContentLayer = contentLayerSingleton(); diff --git a/packages/astro/src/content/instance.ts b/packages/astro/src/content/instance.ts new file mode 100644 index 000000000000..2198440edfbb --- /dev/null +++ b/packages/astro/src/content/instance.ts @@ -0,0 +1,30 @@ +import type { FSWatcher } from 'vite'; +import { ContentLayer } from './content-layer.js'; +import type { Logger } from '../core/logger/core.js'; +import type { AstroSettings } from '../types/astro.js'; +import type { MutableDataStore } from './mutable-data-store.js'; + +interface ContentLayerOptions { + store: MutableDataStore; + settings: AstroSettings; + logger: Logger; + watcher?: FSWatcher; +} + +function contentLayerSingleton() { + let instance: ContentLayer | null = null; + return { + init: (options: ContentLayerOptions) => { + instance?.dispose(); + instance = new ContentLayer(options); + return instance; + }, + get: () => instance, + dispose: () => { + instance?.dispose(); + instance = null; + }, + }; +} + +export const globalContentLayer = contentLayerSingleton(); diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 48c0e008653e..12569a4b9c67 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -19,6 +19,7 @@ import type { LoggerLevel } from '../logger/core.js'; import type { RoutingStrategies } from './common.js'; import type { BaseSessionConfig, SessionDriverFactory } from '../session/types.js'; import type { DevToolbarPlacement } from '../../types/public/toolbar.js'; +import type { MiddlewareMode } from '../../types/public/integrations.js'; import type { BaseApp } from './base.js'; type ComponentPath = string; @@ -89,6 +90,12 @@ export type SSRManifest = { * the creation of `dist/client` and `dist/server` folders. */ serverLike: boolean; + /** + * The middleware mode determines when and how middleware executes. + * - 'classic' (default): Build-time for prerendered pages, request-time for SSR pages + * - 'edge': Middleware deployed as separate edge function + */ + middlewareMode: MiddlewareMode; /** * Map of directive name (e.g. `load`) to the directive script code */ @@ -107,6 +114,7 @@ export type SSRManifest = { sessionDriver?: () => Promise<{ default: SessionDriverFactory | null }>; checkOrigin: boolean; allowedDomains?: Partial[]; + actionBodySizeLimit: number; sessionConfig?: SSRManifestSession; cacheDir: URL; srcDir: URL; diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index c35e5fa1816d..a736d3fb91cd 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -133,7 +133,7 @@ export abstract class Pipeline { } // The middleware can be undefined when using edge middleware. // This is set to undefined by the plugin-ssr.ts - else if (this.middleware) { + if (this.middleware) { const middlewareInstance = await this.middleware(); const onRequest = middlewareInstance.onRequest ?? NOOP_MIDDLEWARE_FN; const internalMiddlewares = [onRequest]; diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index fc5b05c4213a..414402aca3bf 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'; import { glob } from 'tinyglobby'; import { getAssetsPrefix } from '../../../assets/utils/getAssetsPrefix.js'; import { normalizeTheLocale } from '../../../i18n/index.js'; +import { resolveMiddlewareMode } from '../../../integrations/adapter-utils.js'; import { runHookBuildSsr } from '../../../integrations/hooks.js'; import { SERIALIZED_MANIFEST_RESOLVED_ID } from '../../../manifest/serialized.js'; import type { ExtractedChunk } from '../static-build.js'; @@ -83,8 +84,8 @@ export async function manifestBuildPostHook( ); if (ssrManifestChunk) { - const shouldPassMiddlewareEntryPoint = - options.settings.adapter?.adapterFeatures?.edgeMiddleware; + const middlewareMode = resolveMiddlewareMode(options.settings.adapter?.adapterFeatures); + const shouldPassMiddlewareEntryPoint = middlewareMode === 'edge'; await runHookBuildSsr({ config: options.settings.config, manifest, @@ -303,6 +304,8 @@ async function buildManifest( } } + const middlewareMode = resolveMiddlewareMode(opts.settings.adapter?.adapterFeatures); + return { rootDir: opts.settings.config.root.toString(), cacheDir: opts.settings.config.cacheDir.toString(), @@ -315,6 +318,7 @@ async function buildManifest( assetsDir: opts.settings.config.build.assets, routes, serverLike: opts.settings.buildOutput === 'server', + middlewareMode, site: settings.config.site, base: settings.config.base, userAssetsBase: settings.config?.vite?.base, @@ -336,6 +340,10 @@ async function buildManifest( buildFormat: settings.config.build.format, checkOrigin: (settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false, + actionBodySizeLimit: + settings.config.security?.actionBodySizeLimit && settings.buildOutput === 'server' + ? settings.config.security.actionBodySizeLimit + : 1024 * 1024, allowedDomains: settings.config.security?.allowedDomains, key: encodedKey, sessionConfig: sessionConfigToManifest(settings.config.session), diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 477acacea1f0..c5c1d9a1ddae 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -93,6 +93,7 @@ export const ASTRO_CONFIG_DEFAULTS = { checkOrigin: true, allowedDomains: [], csp: false, + actionBodySizeLimit: 1024 * 1024, }, env: { schema: {}, @@ -439,6 +440,10 @@ export const AstroConfigSchema = z.object({ ) .optional() .default(ASTRO_CONFIG_DEFAULTS.security.allowedDomains), + actionBodySizeLimit: z + .number() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.security.actionBodySizeLimit), csp: z .union([ z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.security.csp), diff --git a/packages/astro/src/core/dev/dev.ts b/packages/astro/src/core/dev/dev.ts index 9e11aa70c04f..36ffb1fcbb04 100644 --- a/packages/astro/src/core/dev/dev.ts +++ b/packages/astro/src/core/dev/dev.ts @@ -5,7 +5,8 @@ import { performance } from 'node:perf_hooks'; import colors from 'piccolore'; import { gt, major, minor, patch } from 'semver'; import type * as vite from 'vite'; -import { getDataStoreFile, globalContentLayer } from '../../content/content-layer.js'; +import { getDataStoreFile } from '../../content/content-layer.js'; +import { globalContentLayer } from '../../content/instance.js'; import { attachContentServerListeners } from '../../content/index.js'; import { MutableDataStore } from '../../content/mutable-data-store.js'; import { globalContentConfigObserver } from '../../content/utils.js'; diff --git a/packages/astro/src/core/dev/restart.ts b/packages/astro/src/core/dev/restart.ts index a7572c399de6..93e606bff567 100644 --- a/packages/astro/src/core/dev/restart.ts +++ b/packages/astro/src/core/dev/restart.ts @@ -1,7 +1,7 @@ import type nodeFs from 'node:fs'; import { fileURLToPath } from 'node:url'; import * as vite from 'vite'; -import { globalContentLayer } from '../../content/content-layer.js'; +import { globalContentLayer } from '../../content/instance.js'; import { attachContentServerListeners } from '../../content/server-listeners.js'; import { eventCliSession, telemetry } from '../../events/index.js'; import { SETTINGS_FILE } from '../../preferences/constants.js'; diff --git a/packages/astro/src/core/sync/index.ts b/packages/astro/src/core/sync/index.ts index 261bdd7f8f96..248a2e5bd0ba 100644 --- a/packages/astro/src/core/sync/index.ts +++ b/packages/astro/src/core/sync/index.ts @@ -6,7 +6,8 @@ import colors from 'piccolore'; import { createServer, type FSWatcher, type HotPayload, type ViteDevServer } from 'vite'; import { syncFonts } from '../../assets/fonts/sync.js'; import { CONTENT_TYPES_FILE } from '../../content/consts.js'; -import { getDataStoreFile, globalContentLayer } from '../../content/content-layer.js'; +import { getDataStoreFile } from '../../content/content-layer.js'; +import { globalContentLayer } from '../../content/instance.js'; import { createContentTypesGenerator } from '../../content/index.js'; import { MutableDataStore } from '../../content/mutable-data-store.js'; import { getContentPaths, globalContentConfigObserver } from '../../content/utils.js'; diff --git a/packages/astro/src/integrations/adapter-utils.ts b/packages/astro/src/integrations/adapter-utils.ts new file mode 100644 index 000000000000..f6212f13cdf8 --- /dev/null +++ b/packages/astro/src/integrations/adapter-utils.ts @@ -0,0 +1,33 @@ +import type { AstroAdapterFeatures, MiddlewareMode } from '../types/public/integrations.js'; + +/** + * Resolves the middleware mode from adapter features. + * Handles backward compatibility with the deprecated `edgeMiddleware` flag. + * + * @example + * ```ts + * // New way + * resolveMiddlewareMode({ middlewareMode: 'always' }) // 'always' + * + * // Backward compatibility + * resolveMiddlewareMode({ edgeMiddleware: true }) // 'edge' + * resolveMiddlewareMode({ edgeMiddleware: false }) // 'classic' + * + * // Default + * resolveMiddlewareMode({}) // 'classic' + * ``` + */ +export function resolveMiddlewareMode(features?: AstroAdapterFeatures): MiddlewareMode { + // New property takes precedence + if (features?.middlewareMode) { + return features.middlewareMode; + } + + // Backward compatibility with deprecated edgeMiddleware flag + if (features?.edgeMiddleware === true) { + return 'edge'; + } + + // Default mode + return 'classic'; +} diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index cf21926a280c..776ab92ba3e1 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -7,7 +7,7 @@ import { mergeConfig as mergeViteConfig } from 'vite'; import astroIntegrationActionsRouteHandler from '../actions/integration.js'; import { isActionsFilePresent } from '../actions/utils.js'; import { CONTENT_LAYER_TYPE } from '../content/consts.js'; -import { globalContentLayer } from '../content/content-layer.js'; +import { globalContentLayer } from '../content/instance.js'; import { globalContentConfigObserver } from '../content/utils.js'; import type { SerializedSSRManifest } from '../core/app/types.js'; import type { PageBuildData } from '../core/build/types.js'; diff --git a/packages/astro/src/manifest/serialized.ts b/packages/astro/src/manifest/serialized.ts index f2b5e3e5ca0b..73f2b93b5068 100644 --- a/packages/astro/src/manifest/serialized.ts +++ b/packages/astro/src/manifest/serialized.ts @@ -24,6 +24,7 @@ import { ASTRO_RENDERERS_MODULE_ID } from '../vite-plugin-renderers/index.js'; import { ASTRO_ROUTES_MODULE_ID } from '../vite-plugin-routes/index.js'; import { sessionConfigToManifest } from '../core/session/utils.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js'; +import { resolveMiddlewareMode } from '../integrations/adapter-utils.js'; // This is used by Cloudflare optimizeDeps config export const SERIALIZED_MANIFEST_ID = 'virtual:astro:manifest'; @@ -152,6 +153,7 @@ async function createSerializedManifest(settings: AstroSettings): Promise[]; + /** + * @docs + * @name security.actionBodySizeLimit + * @kind h4 + * @type {number} + * @default `1048576` (1 MB) + * @version 5.18.0 + * @description + * + * Sets the maximum size in bytes allowed for action request bodies. + * + * By default, action request bodies are limited to 1 MB (1048576 bytes) to prevent abuse. + * You can increase this limit if your actions need to accept larger payloads, for example when handling file uploads. + * + * ```js + * // astro.config.mjs + * export default defineConfig({ + * security: { + * actionBodySizeLimit: 10 * 1024 * 1024 // 10 MB + * } + * }) + * ``` + */ + actionBodySizeLimit?: number; + /** * @docs * @name security.csp diff --git a/packages/astro/src/types/public/integrations.ts b/packages/astro/src/types/public/integrations.ts index eb428f6465e4..66883143572b 100644 --- a/packages/astro/src/types/public/integrations.ts +++ b/packages/astro/src/types/public/integrations.ts @@ -81,12 +81,24 @@ export type AdapterSupportWithMessage = { export type AdapterSupport = AdapterSupportsKind | AdapterSupportWithMessage; +export type MiddlewareMode = 'classic' | 'edge'; + export interface AstroAdapterFeatures { /** - * Defines whether any on-demand rendering middleware code will be bundled when built. When enabled, this prevents - * middleware code from being bundled and imported by all pages during the build. + * Creates an edge function that will communicate with the Astro middleware + * + * @deprecated Use `middlewareMode: 'edge'` instead + */ + edgeMiddleware?: boolean; + + /** + * Determines when and how middleware executes: + * - `'classic'` (default): Middleware runs for prerendered pages at build time, and for SSR pages at request time. Does not run for prerendered pages at request time. + * - `'edge'`: Middleware is deployed as a separate edge function. Middleware code will not be bundled and imported by all pages during the build. + * + * @default 'classic' */ - edgeMiddleware: boolean; + middlewareMode?: MiddlewareMode; /** * Allows you to force a specific output shape for the build. This can be useful for adapters that only work with diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 3d8f4b009d5f..f2e5a8793461 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -22,6 +22,7 @@ import { AstroError, AstroErrorData } from '../core/errors/index.js'; import type { Logger } from '../core/logger/core.js'; import { NOOP_MIDDLEWARE_FN } from '../core/middleware/noop-middleware.js'; import { createViteLoader } from '../core/module-loader/index.js'; +import { resolveMiddlewareMode } from '../integrations/adapter-utils.js'; import { SERIALIZED_MANIFEST_ID } from '../manifest/serialized.js'; import type { AstroSettings } from '../types/astro.js'; import { ASTRO_DEV_SERVER_APP_ID } from '../vite-plugin-app/index.js'; @@ -177,6 +178,7 @@ export async function createDevelopmentManifest(settings: AstroSettings): Promis compressHTML: settings.config.compressHTML, assetsDir: settings.config.build.assets, serverLike: settings.buildOutput === 'server', + middlewareMode: resolveMiddlewareMode(settings.adapter?.adapterFeatures), assets: new Set(), entryModules: {}, routes: [], @@ -192,6 +194,9 @@ export async function createDevelopmentManifest(settings: AstroSettings): Promis i18n: i18nManifest, checkOrigin: (settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false, + actionBodySizeLimit: settings.config.security?.actionBodySizeLimit + ? settings.config.security.actionBodySizeLimit + : 1024 * 1024, // 1mb default key: hasEnvironmentKey() ? getEnvironmentKey() : createKey(), middleware() { return { diff --git a/packages/astro/test/content-layer.test.js b/packages/astro/test/content-layer.test.js deleted file mode 100644 index 14a2542ad857..000000000000 --- a/packages/astro/test/content-layer.test.js +++ /dev/null @@ -1,806 +0,0 @@ -import assert from 'node:assert/strict'; -import { existsSync, promises as fs } from 'node:fs'; -import { sep } from 'node:path'; -import { sep as posixSep } from 'node:path/posix'; -import { Writable } from 'node:stream'; -import { after, before, describe, it } from 'node:test'; -import { setTimeout } from 'node:timers/promises'; -import * as cheerio from 'cheerio'; -import * as devalue from 'devalue'; -import { Logger } from '../dist/core/logger/core.js'; - -import { loadFixture } from './test-utils.js'; - -describe('Content Layer', () => { - /** @type {import("./test-utils.js").Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ root: './fixtures/content-layer/' }); - }); - - describe('Build', () => { - let json; - let $; - before(async () => { - fixture = await loadFixture({ root: './fixtures/content-layer/' }); - await fs - .unlink(new URL('./node_modules/.astro/data-store.json', fixture.config.root)) - .catch(() => {}); - await fixture.build({ force: true }); - const rawJson = await fixture.readFile('/collections.json'); - const html = await fixture.readFile('/spacecraft/lunar-module/index.html'); - $ = cheerio.load(html); - json = devalue.parse(rawJson); - }); - - it('Returns custom loader collection', async () => { - assert.ok(json.hasOwnProperty('customLoader')); - assert.ok(Array.isArray(json.customLoader)); - - const item = json.customLoader[0]; - assert.deepEqual(item, { - id: '1', - collection: 'blog', - data: { - userId: 1, - id: 1, - title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit', - body: 'quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto', - }, - }); - }); - - it('filters collection items', async () => { - assert.ok(json.hasOwnProperty('customLoader')); - assert.ok(Array.isArray(json.customLoader)); - assert.equal(json.customLoader.length, 5); - }); - - it('Returns json `file()` loader collection', async () => { - assert.ok(json.hasOwnProperty('jsonLoader')); - assert.ok(Array.isArray(json.jsonLoader)); - - const ids = json.jsonLoader.map((item) => item.data.id); - assert.deepEqual(ids, [ - 'australian-shepherd', - 'beagle', - 'bernese-mountain-dog', - 'boston-terrier', - 'boxer', - 'bulldog', - 'cavalier-king-charles-spaniel', - 'dachshund', - 'doberman-pinscher', - 'english-springer-spaniel', - 'french-bulldog', - 'german-shepherd', - 'german-shorthaired-pointer', - 'golden-retriever', - 'great-dane', - 'havanese', - 'labrador-retriever', - 'miniature-schnauzer', - 'pomeranian', - 'poodle', - 'rottweiler', - 'shetland-sheepdog', - 'shih-tzu', - 'siberian-husky', - 'yorkshire-terrier', - ]); - }); - - it('can render markdown in loaders', async () => { - const html = await fixture.readFile('/index.html'); - assert.ok(cheerio.load(html)('section h1').text().includes('heading 1')); - }); - - it('handles negative matches in glob() loader', async () => { - assert.ok(json.hasOwnProperty('probes')); - assert.ok(Array.isArray(json.probes)); - assert.equal(json.probes.length, 6); - assert.ok( - json.probes.every(({ id }) => !id.startsWith('voyager')), - 'Voyager probes should not be included', - ); - }); - - it('retains body by default in glob() loader', async () => { - assert.ok(json.hasOwnProperty('spacecraftWithBody')); - assert.ok(Array.isArray(json.spacecraftWithBody)); - // All entries should have non-empty body - const columbia = json.spacecraftWithBody.find((s) => s.id === 'columbia'); - assert.ok(columbia, 'columbia entry should exist'); - assert.ok(columbia.body, 'body should be present'); - assert.ok(columbia.body.length > 0, 'body should not be empty'); - assert.ok( - columbia.body.includes('Space Shuttle Columbia'), - 'body should contain markdown content', - ); - }); - - it('clears body when retainBody is false in glob() loader', async () => { - assert.ok(json.hasOwnProperty('spacecraftNoBody')); - assert.ok(Array.isArray(json.spacecraftNoBody)); - // All entries should have undefined body - const columbia = json.spacecraftNoBody.find((s) => s.id === 'columbia'); - assert.ok(columbia, 'columbia entry should exist'); - assert.equal(columbia.body, undefined, 'body should be undefined when retainBody is false'); - }); - - it('Returns nested json `file()` loader collection', async () => { - assert.ok(json.hasOwnProperty('nestedJsonLoader')); - assert.ok(Array.isArray(json.nestedJsonLoader)); - - const ids = json.nestedJsonLoader.map((item) => item.data.id); - assert.deepEqual(ids, ['bluejay', 'cardinal', 'goldfinch', 'robin', 'sparrow']); - }); - - it('can use an async parser in `file()` loader', async () => { - assert.ok(json.hasOwnProperty('loaderWithAsyncParse')); - assert.ok(Array.isArray(json.loaderWithAsyncParse)); - - const ids = json.loaderWithAsyncParse.map((item) => item.data.id); - assert.deepEqual(ids, ['bluejay', 'cardinal', 'goldfinch', 'robin', 'sparrow']); - }); - - it('Returns yaml `file()` loader collection', async () => { - assert.ok(json.hasOwnProperty('yamlLoader')); - assert.ok(Array.isArray(json.yamlLoader)); - - const ids = json.yamlLoader.map((item) => item.id); - assert.deepEqual(ids, [ - 'angel-fish', - 'blue-tail', - 'bubble-buddy', - 'bubbles', - 'finn', - 'gold-stripe', - 'nemo', - 'shadow', - 'spark', - 'splash', - ]); - }); - - it('Returns toml `file()` loader collection', async () => { - assert.ok(json.hasOwnProperty('tomlLoader')); - assert.ok(Array.isArray(json.tomlLoader)); - - const ids = json.tomlLoader.map((item) => item.id); - assert.deepEqual(ids, [ - 'crown', - 'family-ties', - 'honest', - 'never-let-me-down', - 'nikes-on-my-feet', - 'no-church-in-the-wild', - 'somebody', - 'stars', - ]); - }); - - it('Returns csv `file()` loader collection', async () => { - assert.ok(json.hasOwnProperty('csvLoader')); - assert.ok(Array.isArray(json.csvLoader)); - - const ids = json.csvLoader.map((item) => item.data.id); - assert.deepEqual(ids, [ - 'basil', - 'chamomile', - 'daisy', - 'fern', - 'lavender', - 'marigold', - 'rose', - 'sage', - 'sunflower', - 'thyme', - ]); - }); - - it('Returns yaml `glob()` loader collection', async () => { - assert.ok(json.hasOwnProperty('numbersYaml')); - assert.ok(Array.isArray(json.numbersYaml)); - - const titles = json.numbersYaml.map((item) => item.data.title).sort(); - assert.deepEqual(titles, ['One', 'Three', 'Two']); - }); - - it('Returns toml `glob()` loader collection', async () => { - assert.ok(json.hasOwnProperty('numbersToml')); - assert.ok(Array.isArray(json.numbersToml)); - - const titles = json.numbersToml.map((item) => item.data.title).sort(); - assert.deepEqual(titles, ['One', 'Three', 'Two']); - }); - - it('Returns nested json `file()` loader collection', async () => { - assert.ok(json.hasOwnProperty('nestedJsonLoader')); - assert.ok(Array.isArray(json.nestedJsonLoader)); - - const ids = json.nestedJsonLoader.map((item) => item.data.id); - assert.deepEqual(ids, ['bluejay', 'cardinal', 'goldfinch', 'robin', 'sparrow']); - }); - - it('Returns data entry by id', async () => { - assert.ok(json.hasOwnProperty('dataEntry')); - assert.equal(json.dataEntry.filePath?.split(sep).join(posixSep), 'src/data/dogs.json'); - delete json.dataEntry.filePath; - assert.deepEqual(json.dataEntry, { - id: 'beagle', - collection: 'dogs', - data: { - breed: 'Beagle', - id: 'beagle', - size: 'Small to Medium', - origin: 'England', - lifespan: '12-15 years', - temperament: ['Friendly', 'Curious', 'Merry'], - }, - }); - }); - - it('returns collection from a simple loader', async () => { - assert.ok(json.hasOwnProperty('simpleLoader')); - assert.ok(Array.isArray(json.simpleLoader)); - - const item = json.simpleLoader.find((i) => i.id === 'siamese'); - assert.deepEqual(item, { - id: 'siamese', - collection: 'cats', - data: { - breed: 'Siamese', - id: 'siamese', - size: 'Medium', - origin: 'Thailand', - lifespan: '15 years', - temperament: ['Active', 'Affectionate', 'Social', 'Playful'], - }, - }); - }); - - it('returns a collection from a simple loader that uses an object', async () => { - assert.ok(json.hasOwnProperty('simpleLoaderObject')); - assert.ok(Array.isArray(json.simpleLoaderObject)); - assert.deepEqual(json.simpleLoaderObject[0], { - id: 'capybara', - collection: 'rodents', - data: { - name: 'Capybara', - scientificName: 'Hydrochoerus hydrochaeris', - lifespan: 10, - weight: 50000, - diet: ['grass', 'aquatic plants', 'bark', 'fruits'], - nocturnal: false, - }, - }); - }); - - // Regression test for https://github.com/withastro/astro/issues/12689 - it('returns a collection from a loader that uses dynamic import', async () => { - assert.ok(json.hasOwnProperty('dynamicImportLoader')); - assert.ok(Array.isArray(json.dynamicImportLoader)); - // Should have loaded cats.json via dynamic import - const ids = json.dynamicImportLoader.map((item) => item.data.id); - assert.ok(ids.includes('siamese'), 'Should include siamese cat'); - assert.ok(ids.includes('tabby'), 'Should include tabby cat'); - }); - - it('transforms a reference id to a reference object', async () => { - assert.ok(json.hasOwnProperty('entryWithReference')); - assert.deepEqual(json.entryWithReference.data.cat, { collection: 'cats', id: 'tabby' }); - }); - - it('can store Date objects', async () => { - assert.ok(json.entryWithReference.data.publishedDate instanceof Date); - }); - - it('loads images in frontmatter', async () => { - assert.ok(json.entryWithReference.data.heroImage.src.startsWith('/_astro')); - assert.equal(json.entryWithReference.data.heroImage.format, 'jpg'); - }); - - it('loads images with uppercase extensions', async () => { - assert.ok(json.atlantis.data.heroImage.src.startsWith('/_astro')); - assert.ok(json.atlantis.data.heroImage.src.endsWith('.JPG')); - assert.equal(json.atlantis.data.heroImage.format, 'jpg'); - }); - - it('loads images from custom loaders', async () => { - assert.ok(json.images[0].data.image.src.startsWith('/_astro')); - assert.equal(json.images[0].data.image.format, 'jpg'); - }); - - it('loads images with absolute paths', async () => { - assert.ok(json.entryWithImagePath.data.heroImage.src.startsWith('/_astro')); - assert.equal(json.entryWithImagePath.data.heroImage.format, 'jpg'); - }); - - it('handles remote images in custom loaders', async () => { - assert.ok(json.images[1].data.image.startsWith('https://')); - }); - - it('loads images with bare filenames in JSON', async () => { - assert.ok(json.rockets[0].data.image.src.startsWith('/_astro')); - assert.equal(json.rockets[0].data.image.format, 'jpg'); - }); - - it('loads images with relative paths in JSON', async () => { - assert.ok(json.rockets[1].data.image.src.startsWith('/_astro')); - assert.equal(json.rockets[1].data.image.format, 'jpg'); - }); - - it('renders images from frontmatter', async () => { - assert.ok($('img[alt="Lunar Module"]').attr('src').startsWith('/_astro')); - }); - - it('displays public images unchanged', async () => { - assert.equal($('img[alt="buzz"]').attr('src'), '/buzz.jpg'); - }); - - it('renders local images', async () => { - assert.ok($('img[alt="shuttle"]').attr('src').startsWith('/_astro')); - }); - - it('escapes alt text in markdown', async () => { - assert.equal($('img[alt^="xss"]').attr('alt'), 'xss ">'); - }); - - it('returns a referenced entry', async () => { - assert.ok(json.hasOwnProperty('referencedEntry')); - assert.deepEqual(json.referencedEntry, { - collection: 'cats', - data: { - breed: 'Tabby', - id: 'tabby', - size: 'Medium', - origin: 'Egypt', - lifespan: '15 years', - temperament: ['Curious', 'Playful', 'Independent'], - }, - id: 'tabby', - }); - }); - - it('allows "slug" as a field', async () => { - assert.equal(json.increment.data.slug, 'slimy'); - }); - - it('renderMarkdown parses frontmatter correctly', async () => { - const entry = json.renderMarkdownTest; - assert.ok(entry, 'renderMarkdownTest entry should exist'); - - // The frontmatter should be parsed and available in metadata - const metadata = entry.data.renderedMetadata; - assert.ok(metadata?.frontmatter, 'metadata.frontmatter should exist'); - assert.equal(metadata.frontmatter.title, 'Test Post', 'frontmatter.title should be parsed'); - assert.equal( - metadata.frontmatter.description, - 'A test post for renderMarkdown', - 'frontmatter.description should be parsed', - ); - assert.deepEqual( - metadata.frontmatter.tags, - ['test', 'markdown'], - 'frontmatter.tags should be parsed', - ); - }); - - it('renderMarkdown excludes frontmatter from HTML output', async () => { - const entry = json.renderMarkdownTest; - const html = entry.data.renderedHtml; - - // The HTML should NOT contain the frontmatter - assert.ok(!html.includes('title: Test Post'), 'HTML should not contain frontmatter title'); - assert.ok(!html.includes('description:'), 'HTML should not contain frontmatter description'); - - // The HTML should contain the actual content - assert.ok(html.includes('Hello World'), 'HTML should contain the body content'); - assert.ok(html.includes('Subheading'), 'HTML should contain the subheading'); - }); - - it('renderMarkdown extracts headings correctly', async () => { - const entry = json.renderMarkdownTest; - const metadata = entry.data.renderedMetadata; - - // Headings should be from the content, not frontmatter - assert.ok(Array.isArray(metadata?.headings), 'metadata.headings should be an array'); - const headingTexts = metadata.headings.map((h) => h.text); - assert.ok(headingTexts.includes('Hello World'), 'headings should include "Hello World"'); - assert.ok(headingTexts.includes('Subheading'), 'headings should include "Subheading"'); - // Frontmatter keys should NOT appear as headings - assert.ok( - !headingTexts.some((t) => t.includes('title:')), - 'headings should not include frontmatter', - ); - }); - - it('renderMarkdown resolves relative image paths when fileURL is provided', async () => { - const entry = json.renderMarkdownWithImage; - assert.ok(entry, 'renderMarkdownWithImage entry should exist'); - - const metadata = entry.data.renderedMetadata; - // When fileURL is provided, relative image paths should be resolved - assert.ok( - Array.isArray(metadata?.localImagePaths), - 'metadata.localImagePaths should be an array', - ); - assert.ok( - metadata.localImagePaths.length > 0, - 'localImagePaths should contain the relative image', - ); - // The path should be resolved relative to the fileURL - assert.ok( - metadata.localImagePaths[0].includes('image.png'), - 'localImagePaths should include the image filename', - ); - }); - - it('updates the store on new builds', async () => { - assert.equal(json.increment.data.lastValue, 1); - assert.equal(json.entryWithReference.data.something?.content, 'transform me'); - await fixture.build(); - const newJson = devalue.parse(await fixture.readFile('/collections.json')); - assert.equal(newJson.increment.data.lastValue, 2); - assert.equal(newJson.entryWithReference.data.something?.content, 'transform me'); - }); - - it('clears the store on new build with force flag', async () => { - let newJson = devalue.parse(await fixture.readFile('/collections.json')); - assert.equal(newJson.increment.data.lastValue, 2); - assert.equal(newJson.entryWithReference.data.something?.content, 'transform me'); - await fixture.build({ force: true }, {}); - newJson = devalue.parse(await fixture.readFile('/collections.json')); - assert.equal(newJson.increment.data.lastValue, 1); - assert.equal(newJson.entryWithReference.data.something?.content, 'transform me'); - }); - - it('clears the store on new build if the content config has changed', async () => { - let newJson = devalue.parse(await fixture.readFile('/collections.json')); - assert.equal(newJson.increment.data.lastValue, 1); - await fixture.editFile('src/content.config.ts', (prev) => { - return `${prev}\nexport const foo = 'bar';`; - }); - await fixture.build(); - newJson = devalue.parse(await fixture.readFile('/collections.json')); - assert.equal(newJson.increment.data.lastValue, 1); - await fixture.resetAllFiles(); - }); - - it('clears the store on new build if the Astro config has changed', async () => { - let newJson = devalue.parse(await fixture.readFile('/collections.json')); - assert.equal(newJson.increment.data.lastValue, 1); - await fixture.editFile('astro.config.mjs', (prev) => { - return prev.replace('Astro content layer', 'Astro more content layer'); - }); - await fixture.build(); - newJson = devalue.parse(await fixture.readFile('/collections.json')); - assert.equal(newJson.increment.data.lastValue, 1); - await fixture.resetAllFiles(); - }); - - it('can handle references being renamed after a build', async () => { - let newJson = devalue.parse(await fixture.readFile('/collections.json')); - assert.deepEqual(newJson.entryWithReference.data.cat, { collection: 'cats', id: 'tabby' }); - await fixture.editFile('src/data/cats.json', (prev) => { - return prev.replace('tabby', 'tabby-cat'); - }); - await fixture.editFile('src/content/space/columbia-copy.md', (prev) => { - return prev.replace('cat: tabby', 'cat: tabby-cat'); - }); - await fixture.build(); - newJson = devalue.parse(await fixture.readFile('/collections.json')); - assert.deepEqual(newJson.entryWithReference.data.cat, { - collection: 'cats', - id: 'tabby-cat', - }); - await fixture.resetAllFiles(); - }); - }); - - describe('Dev', () => { - let devServer; - let json; - const logs = []; - before(async () => { - devServer = await fixture.startDevServer({ - force: true, - logger: new Logger({ - level: 'info', - dest: new Writable({ - objectMode: true, - write(event, _, callback) { - logs.push(event); - callback(); - }, - }), - }), - }); - // Vite may not have noticed the saved data store yet. Wait a little just in case. - await fixture.onNextDataStoreChange(1000).catch(() => { - // Ignore timeout, because it may have saved before we get here. - }); - const rawJsonResponse = await fixture.fetch('/collections.json'); - const rawJson = await rawJsonResponse.text(); - json = devalue.parse(rawJson); - }); - - after(async () => { - devServer?.stop(); - }); - - it("warns about missing directory in glob() loader's path", async () => { - assert.ok(logs.find((log) => log.level === 'warn' && log.message.includes('does not exist'))); - }); - - it('warns about duplicate IDs in file() loader arrays', () => { - assert.ok( - logs.find( - (log) => - log.level === 'warn' && - log.message.includes('Duplicate id "german-shepherd" found in src/data/dogs.json'), - ), - ); - }); - - it('warns about duplicate IDs in glob() loader', () => { - assert.ok( - logs.find((log) => log.level === 'warn' && log.message.includes('Duplicate id "cassini"')), - ); - }); - - it("warns about missing files in glob() loader's path", async () => { - assert.ok( - logs.find((log) => log.level === 'warn' && log.message.includes('No files found matching')), - ); - }); - - it('Generates content types files', async () => { - assert.ok(existsSync(new URL('./.astro/content.d.ts', fixture.config.root))); - const data = await fs.readFile(new URL('./.astro/types.d.ts', fixture.config.root), 'utf-8'); - assert.match(data, / { - assert.ok(json.hasOwnProperty('customLoader')); - assert.ok(Array.isArray(json.customLoader)); - - const item = json.customLoader[0]; - assert.deepEqual(item, { - id: '1', - collection: 'blog', - data: { - userId: 1, - id: 1, - title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit', - body: 'quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto', - }, - }); - }); - - it('Returns `file()` loader collection', async () => { - assert.ok(json.hasOwnProperty('jsonLoader')); - assert.ok(Array.isArray(json.jsonLoader)); - - const ids = json.jsonLoader.map((item) => item.data.id); - assert.deepEqual(ids, [ - 'australian-shepherd', - 'beagle', - 'bernese-mountain-dog', - 'boston-terrier', - 'boxer', - 'bulldog', - 'cavalier-king-charles-spaniel', - 'dachshund', - 'doberman-pinscher', - 'english-springer-spaniel', - 'french-bulldog', - 'german-shepherd', - 'german-shorthaired-pointer', - 'golden-retriever', - 'great-dane', - 'havanese', - 'labrador-retriever', - 'miniature-schnauzer', - 'pomeranian', - 'poodle', - 'rottweiler', - 'shetland-sheepdog', - 'shih-tzu', - 'siberian-husky', - 'yorkshire-terrier', - ]); - }); - - it('Returns data entry by id', async () => { - assert.ok(json.hasOwnProperty('dataEntry')); - assert.equal(json.dataEntry.filePath?.split(sep).join(posixSep), 'src/data/dogs.json'); - delete json.dataEntry.filePath; - assert.deepEqual(json.dataEntry, { - id: 'beagle', - collection: 'dogs', - data: { - breed: 'Beagle', - id: 'beagle', - size: 'Small to Medium', - origin: 'England', - lifespan: '12-15 years', - temperament: ['Friendly', 'Curious', 'Merry'], - }, - }); - }); - - it('reloads data when an integration triggers a content refresh', async () => { - const rawJsonResponse = await fixture.fetch('/collections.json'); - const initialJson = devalue.parse(await rawJsonResponse.text()); - assert.equal(initialJson.increment.data.lastValue, 1); - const now = new Date().toISOString(); - - const refreshResponse = await fixture.fetch('/_refresh', { - method: 'POST', - body: JSON.stringify({ now }), - }); - const refreshData = await refreshResponse.json(); - assert.equal(refreshData.message, 'Content refreshed successfully'); - const updatedJsonResponse = await fixture.fetch('/collections.json'); - const updated = devalue.parse(await updatedJsonResponse.text()); - assert.equal(updated.increment.data.lastValue, 2); - assert.deepEqual(updated.increment.data.refreshContextData, { webhookBody: { now } }); - }); - - it('updates collection when data file is changed', async () => { - const rawJsonResponse = await fixture.fetch('/collections.json'); - const initialJson = devalue.parse(await rawJsonResponse.text()); - const initialLabrador = initialJson.jsonLoader.find( - (item) => item.data.id === 'labrador-retriever', - ); - assert.equal(initialLabrador.data.temperament.includes('Bouncy'), false); - - await fixture.editFile('/src/data/dogs.json', (prev) => { - const data = JSON.parse(prev); - data[0].temperament.push('Bouncy'); - return JSON.stringify(data, null, 2); - }); - - await fixture.onNextDataStoreChange(); - const updatedJsonResponse = await fixture.fetch('/collections.json'); - const updated = devalue.parse(await updatedJsonResponse.text()); - const updatedLabrador = updated.jsonLoader.find( - (item) => item.data.id === 'labrador-retriever', - ); - assert.ok(updatedLabrador.data.temperament.includes('Bouncy')); - await fixture.resetAllFiles(); - }); - - it('removes old entry when slug is changed', async () => { - const rawJsonResponse = await fixture.fetch('/collections.json'); - const initialJson = devalue.parse(await rawJsonResponse.text()); - - assert.ok(initialJson.spacecraft.includes('exomars')); - assert.ok(!initialJson.spacecraft.includes('rosalind-franklin-rover')); - - await fixture.editFile('/src/content/space/exomars.md', (prev) => { - return prev.replace('# slug', 'slug'); - }); - - await fixture.onNextDataStoreChange(); - const updatedJsonResponse = await fixture.fetch('/collections.json'); - const updated = devalue.parse(await updatedJsonResponse.text()); - assert.ok(!updated.spacecraft.includes('exomars')); - assert.ok(updated.spacecraft.includes('rosalind-franklin-rover')); - - await fixture.editFile('/src/content/space/exomars.md', (prev) => { - return prev.replace('rosalind-franklin-rover', 'rosalind-franklin'); - }); - - await fixture.onNextDataStoreChange(); - const updatedJsonResponse2 = await fixture.fetch('/collections.json'); - const updated2 = devalue.parse(await updatedJsonResponse2.text()); - assert.ok(!updated2.spacecraft.includes('rosalind-franklin-rover')); - assert.ok(updated2.spacecraft.includes('rosalind-franklin')); - - await fixture.resetAllFiles(); - }); - - it('does not warn about duplicate IDs when a file is edited', async () => { - logs.length = 0; - - await fixture.editFile('/src/content/space/endeavour.md', (prev) => { - return prev.replace('learn about the', 'Learn about the'); - }); - - await fixture.onNextDataStoreChange(); - - const duplicateWarning = logs.find((log) => log.message.includes('Duplicate id "endeavour"')); - assert.ok(!duplicateWarning, 'Should not warn about duplicate ID when editing same file'); - - await fixture.resetAllFiles(); - }); - - it('does not warn about duplicate IDs when a file with a slug is renamed', async () => { - logs.length = 0; - - // dawn.md has slug: dawn-mission - renaming should not cause duplicate warning - const oldPath = new URL('./data/space-probes/dawn.md', fixture.config.srcDir); - const newPath = new URL('./data/space-probes/dawn-renamed.md', fixture.config.srcDir); - - await fs.rename(oldPath, newPath); - await fixture.onNextDataStoreChange(); - - try { - const duplicateWarning = logs.find((log) => - log.message.includes('Duplicate id "dawn-mission"'), - ); - assert.ok(!duplicateWarning, 'Should not warn about duplicate ID when renaming file'); - } finally { - await fs.rename(newPath, oldPath); - } - }); - - it('returns an error if we render an undefined entry', async () => { - const res = await fixture.fetch('/missing'); - const text = await res.text(); - assert.equal(res.status, 500); - assert.ok(text.includes('RenderUndefinedEntryError')); - }); - - it('update the store when a file is renamed', async () => { - const rawJsonResponse = await fixture.fetch('/collections.json'); - const initialJson = devalue.parse(await rawJsonResponse.text()); - assert.equal(initialJson.numbers.map((e) => e.id).includes('src/data/glob-data/three'), true); - - const oldPath = new URL('./data/glob-data/three.json', fixture.config.srcDir); - const newPath = new URL('./data/glob-data/four.json', fixture.config.srcDir); - - await fs.rename(oldPath, newPath); - await fixture.onNextDataStoreChange(); - - try { - const updatedJsonResponse = await fixture.fetch('/collections.json'); - const updated = devalue.parse(await updatedJsonResponse.text()); - assert.equal(updated.numbers.map((e) => e.id).includes('src/data/glob-data/three'), false); - assert.equal(updated.numbers.map((e) => e.id).includes('src/data/glob-data/four'), true); - } finally { - await fs.rename(newPath, oldPath); - } - }); - - it('still updates collection when data file is changed after server has restarted via config change', async () => { - await fixture.editFile('astro.config.mjs', (prev) => - prev.replace("'Astro content layer'", "'Astro content layer edited'"), - ); - logs.length = 0; - - // Give time for the server to restart - await setTimeout(5000); - - const rawJsonResponse = await fixture.fetch('/collections.json'); - const initialJson = devalue.parse(await rawJsonResponse.text()); - const initialLabrador = initialJson.jsonLoader.find( - (item) => item.data.id === 'labrador-retriever', - ); - assert.equal(initialLabrador.data.temperament.includes('Bouncy'), false); - - await fixture.editFile('/src/data/dogs.json', (prev) => { - const data = JSON.parse(prev); - data[0].temperament.push('Bouncy'); - return JSON.stringify(data, null, 2); - }); - - await fixture.onNextDataStoreChange(); - const updatedJsonResponse = await fixture.fetch('/collections.json'); - const updated = devalue.parse(await updatedJsonResponse.text()); - const updatedLabrador = updated.jsonLoader.find( - (item) => item.data.id === 'labrador-retriever', - ); - assert.ok(updatedLabrador.data.temperament.includes('Bouncy')); - logs.length = 0; - - await fixture.resetAllFiles(); - // Give time for the server to restart again - await setTimeout(5000); - }); - }); -}); diff --git a/packages/astro/test/i18n-routing-manual-with-default-middleware.test.js b/packages/astro/test/i18n-routing-manual-with-default-middleware.test.js deleted file mode 100644 index 0b24c6aa7aa5..000000000000 --- a/packages/astro/test/i18n-routing-manual-with-default-middleware.test.js +++ /dev/null @@ -1,129 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import testAdapter from './test-adapter.js'; -import { loadFixture } from './test-utils.js'; - -// DEV -describe('Dev server manual routing', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/i18n-routing-manual-with-default-middleware/', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('should return a 404', async () => { - const response = await fixture.fetch('/blog'); - const text = await response.text(); - assert.equal(response.status, 404); - assert.match(text, /Blog should not render/); - }); - - it('should return a 200 because the custom middleware allows it', async () => { - const response = await fixture.fetch('/about'); - assert.equal(response.status, 200); - const text = await response.text(); - assert.equal(text.includes('ABOUT ME'), true); - }); - - it('should correctly print the relative locale url', async () => { - const response = await fixture.fetch('/en/start'); - assert.equal(response.status, 200); - const html = await response.text(); - const $ = cheerio.load(html); - assert.equal($('p').text(), '/en/blog/title/'); - }); -}); -// -// // SSG -describe('SSG manual routing', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/i18n-routing-manual-with-default-middleware/', - }); - await fixture.build(); - }); - - it('should return a 404', async () => { - try { - await fixture.readFile('/blog.html'); - assert.fail(); - } catch {} - }); - - it('should return a 200 because the custom middleware allows it', async () => { - let html = await fixture.readFile('/about/index.html'); - let $ = cheerio.load(html); - assert.equal($('body').text().includes('ABOUT ME'), true); - }); - - it('should correctly print the relative locale url', async () => { - const html = await fixture.readFile('/en/start/index.html'); - const $ = cheerio.load(html); - assert.equal($('p').text(), '/en/blog/title/'); - }); -}); - -// // SSR -describe('SSR manual routing', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let app; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/i18n-routing-manual-with-default-middleware/', - output: 'server', - adapter: testAdapter(), - }); - await fixture.build(); - app = await fixture.loadTestAdapterApp(); - }); - - it('should return a 404', async () => { - let request = new Request('http://example.com/blog'); - let response = await app.render(request); - assert.equal(response.status, 404); - const text = await response.text(); - assert.match(text, /Blog should not render/); - }); - - it('should return a 200 because the custom middleware allows it', async () => { - let request = new Request('http://example.com/about'); - let response = await app.render(request); - assert.equal(response.status, 200); - const text = await response.text(); - assert.equal(text.includes('ABOUT ME'), true); - }); - - it('should correctly print the relative locale url', async () => { - let request = new Request('http://example.com/en/start'); - let response = await app.render(request); - assert.equal(response.status, 200); - const html = await response.text(); - const $ = cheerio.load(html); - assert.equal($('p').text(), '/en/blog/title/'); - }); - - it('should use the fallback', async () => { - let request = new Request('http://example.com/it/start'); - let response = await app.render(request); - assert.equal(response.status, 200); - const html = await response.text(); - const $ = cheerio.load(html); - assert.equal($('p').text(), '/en/blog/title/'); - }); -}); diff --git a/packages/astro/test/i18n-routing-manual.test.js b/packages/astro/test/i18n-routing-manual.test.js deleted file mode 100644 index 3a9efe643402..000000000000 --- a/packages/astro/test/i18n-routing-manual.test.js +++ /dev/null @@ -1,150 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import testAdapter from './test-adapter.js'; -import { loadFixture } from './test-utils.js'; - -// DEV -describe('Dev server manual routing', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/i18n-routing-manual/', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('should redirect to the default locale when middleware calls the function for route /', async () => { - const response = await fixture.fetch('/'); - assert.equal(response.status, 200); - const text = await response.text(); - assert.equal(text.includes('Hello'), true); - }); - - it('should render a route that is not related to the i18n routing', async () => { - const response = await fixture.fetch('/help'); - assert.equal(response.status, 200); - const text = await response.text(); - assert.equal(text.includes('Outside route'), true); - }); - - it('should render a i18n route', async () => { - let response = await fixture.fetch('/en/blog'); - assert.equal(response.status, 200); - let text = await response.text(); - assert.equal(text.includes('Blog start'), true); - - response = await fixture.fetch('/pt/start'); - assert.equal(response.status, 200); - text = await response.text(); - assert.equal(text.includes('Oi'), true); - - response = await fixture.fetch('/spanish'); - assert.equal(response.status, 200); - text = await response.text(); - assert.equal(text.includes('Hola.'), true); - }); - - it('should call the middleware for 404.astro pages', async () => { - const response = await fixture.fetch('/redirect-me'); - assert.equal(response.status, 200); - }); -}); - -// SSG -describe('SSG manual routing', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/i18n-routing-manual/', - }); - await fixture.build(); - }); - - it('should redirect to the default locale when middleware calls the function for route /', async () => { - let html = await fixture.readFile('/index.html'); - assert.equal(html.includes('http-equiv="refresh'), true); - assert.equal(html.includes('url=/en'), true); - }); - - it('should render a route that is not related to the i18n routing', async () => { - let html = await fixture.readFile('/help/index.html'); - let $ = cheerio.load(html); - assert.equal($('body').text().includes('Outside route'), true); - }); - - it('should render a i18n route', async () => { - let html = await fixture.readFile('/en/blog/index.html'); - let $ = cheerio.load(html); - assert.equal($('body').text().includes('Blog start'), true); - - html = await fixture.readFile('/pt/start/index.html'); - $ = cheerio.load(html); - assert.equal($('body').text().includes('Oi'), true); - - html = await fixture.readFile('/spanish/index.html'); - $ = cheerio.load(html); - assert.equal($('body').text().includes('Hola.'), true); - }); -}); - -// SSR -describe('SSR manual routing', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let app; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/i18n-routing-manual/', - output: 'server', - adapter: testAdapter(), - }); - await fixture.build(); - app = await fixture.loadTestAdapterApp(); - }); - - it('should redirect to the default locale when middleware calls the function for route /', async () => { - let request = new Request('http://example.com/'); - let response = await app.render(request); - assert.equal(response.status, 302); - }); - - it('should render a route that is not related to the i18n routing', async () => { - let request = new Request('http://example.com/help'); - let response = await app.render(request); - assert.equal(response.status, 200); - const text = await response.text(); - assert.equal(text.includes('Outside route'), true); - }); - - it('should render a i18n route', async () => { - let request = new Request('http://example.com/en/blog'); - let response = await app.render(request); - assert.equal(response.status, 200); - let text = await response.text(); - assert.equal(text.includes('Blog start'), true); - - request = new Request('http://example.com/pt/start'); - response = await app.render(request); - assert.equal(response.status, 200); - text = await response.text(); - assert.equal(text.includes('Oi'), true); - - request = new Request('http://example.com/spanish'); - response = await app.render(request); - assert.equal(response.status, 200); - text = await response.text(); - assert.equal(text.includes('Hola.'), true); - }); -}); diff --git a/packages/astro/test/middleware.test.js b/packages/astro/test/middleware.test.js index c47d1604618f..a3558a642111 100644 --- a/packages/astro/test/middleware.test.js +++ b/packages/astro/test/middleware.test.js @@ -423,7 +423,7 @@ describe('Middleware API in PROD mode, SSR', () => { adapter: testAdapter({ extendAdapter: { adapterFeatures: { - edgeMiddleware: true, + middlewareMode: 'edge', }, }, setMiddlewareEntryPoint(middlewareEntryPoint) { diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index f17f57df5902..7d5e25d4783b 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { glob } from 'tinyglobby'; import { Agent } from 'undici'; import { check } from '../dist/cli/check/index.js'; -import { globalContentLayer } from '../dist/content/content-layer.js'; +import { globalContentLayer } from '../dist/content/instance.js'; import { globalContentConfigObserver } from '../dist/content/utils.js'; import build from '../dist/core/build/index.js'; import { mergeConfig, resolveConfig } from '../dist/core/config/index.js'; diff --git a/packages/astro/test/units/content-layer/core-loader.test.js b/packages/astro/test/units/content-layer/core-loader.test.js new file mode 100644 index 000000000000..9c953484b555 --- /dev/null +++ b/packages/astro/test/units/content-layer/core-loader.test.js @@ -0,0 +1,290 @@ +import { strict as assert } from 'node:assert'; +import { describe, it, before } from 'node:test'; +import { z } from 'zod'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { Logger } from '../../../dist/core/logger/core.js'; + +import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.js'; + +describe('Core Content Layer loader', () => { + let logger; + const root = createTempDir(); + + before(() => { + logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + }); + + it('returns collection from a simple loader', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + + // Create a simple loader + const simpleLoader = () => [ + { id: 'siamese', breed: 'Siamese' }, + { id: 'tabby', breed: 'Tabby' }, + ]; + + // Define collections + const collections = { + cats: defineCollection({ + loader: simpleLoader, + schema: z.object({ + id: z.string(), + breed: z.string(), + }), + }), + }; + + // Create ContentLayer with test config + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // Sync content + await contentLayer.sync(); + + const entries = store.values('cats'); + assert.equal(entries.length, 2); + assert.equal(entries[0].id, 'siamese'); + assert.equal(entries[1].id, 'tabby'); + }); + + it('returns collection from a simple loader that uses an object', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + + const objectLoader = () => ({ + capybara: { + name: 'Capybara', + scientificName: 'Hydrochoerus hydrochaeris', + lifespan: 10, + weight: 50000, + diet: ['grass', 'aquatic plants', 'bark', 'fruits'], + nocturnal: false, + }, + hamster: { + name: 'Golden Hamster', + scientificName: 'Mesocricetus auratus', + lifespan: 2, + weight: 120, + diet: ['seeds', 'nuts', 'insects'], + nocturnal: true, + }, + }); + + // Define collections + const collections = { + rodents: defineCollection({ + loader: objectLoader, + schema: z.object({ + name: z.string(), + scientificName: z.string(), + lifespan: z.number().int().positive(), + weight: z.number().positive(), + diet: z.array(z.string()), + nocturnal: z.boolean(), + }), + }), + }; + + // Create ContentLayer with test config + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // Sync content + await contentLayer.sync(); + + const entries = store.values('rodents'); + assert.equal(entries.length, 2); + + const capybara = entries.find((e) => e.id === 'capybara'); + assert.ok(capybara); + assert.equal(capybara.data.name, 'Capybara'); + assert.equal(capybara.data.weight, 50000); + }); + + it('can render markdown in loaders', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + + const markdownContent = ` +# heading 1 +hello +## heading 2 +![image](./image.png) +![image 2](https://example.com/image.png) +`; + + // Create a loader that renders markdown + const markdownRenderingLoader = { + name: 'markdown-rendering-loader', + load: async (context) => { + const result = await context.renderMarkdown(markdownContent, { + fileURL: new URL('test.md', root), + }); + + const data = { + lastValue: 1, + lastUpdated: new Date(), + // Store rendered content in data for this test + renderedHtml: result.html, + headingsCount: result.metadata.headings.length, + }; + + const parsed = await context.parseData({ + id: 'value', + data, + }); + + await context.store.set({ + id: 'value', + data: parsed, + }); + }, + }; + + // Define collections + const collections = { + increment: defineCollection({ + loader: markdownRenderingLoader, + schema: z.object({ + lastValue: z.number(), + lastUpdated: z.date(), + renderedHtml: z.string(), + headingsCount: z.number(), + }), + }), + }; + + // Create ContentLayer with test config + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // Sync content + await contentLayer.sync(); + + const entry = store.get('increment', 'value'); + assert.ok(entry); + assert.ok(entry.data.renderedHtml); + assert.ok(entry.data.renderedHtml.includes('

heading 1

')); + assert.ok(entry.data.renderedHtml.includes('

heading 2

')); + assert.equal(entry.data.headingsCount, 2); + }); + + it('stores Date objects', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const now = new Date(); + + // Create a loader that returns Date objects + const dateLoader = { + name: 'date-loader', + load: async (context) => { + await context.store.set({ + id: 'test-date', + data: { + created: now, + title: 'Test', + }, + }); + }, + }; + + // Define collections + const collections = { + dates: defineCollection({ + loader: dateLoader, + schema: z.object({ + created: z.date(), + title: z.string(), + }), + }), + }; + + // Create ContentLayer with test config + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // Sync content + await contentLayer.sync(); + + const entry = store.get('dates', 'test-date'); + assert.ok(entry); + assert.ok(entry.data.created instanceof Date); + assert.equal(entry.data.created.toISOString(), now.toISOString()); + }); + + it('allows "slug" as a field', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + + // Create a loader that uses slug field + const slugLoader = { + name: 'slug-loader', + load: async (context) => { + const data = { + lastValue: 1, + lastUpdated: new Date(), + slug: 'slimy', + }; + + const parsed = await context.parseData({ + id: 'value', + data, + }); + + await context.store.set({ + id: 'value', + data: parsed, + }); + }, + }; + + // Define collections + const collections = { + increment: defineCollection({ + loader: slugLoader, + schema: z.object({ + lastValue: z.number(), + lastUpdated: z.date(), + slug: z.string().optional(), + }), + }), + }; + + // Create ContentLayer with test config + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // Sync content + await contentLayer.sync(); + + const entry = store.get('increment', 'value'); + assert.ok(entry); + assert.equal(entry.data.slug, 'slimy'); + }); +}); diff --git a/packages/astro/test/units/content-layer/data-transforms.test.js b/packages/astro/test/units/content-layer/data-transforms.test.js new file mode 100644 index 000000000000..c4b68f818046 --- /dev/null +++ b/packages/astro/test/units/content-layer/data-transforms.test.js @@ -0,0 +1,517 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { z } from 'zod'; +import { defineCollection } from '../../../dist/content/config.js'; +import { createReference } from '../../../dist/content/runtime.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { Logger } from '../../../dist/core/logger/core.js'; +import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.js'; + +describe('Content Layer - Data Transforms', () => { + const root = createTempDir(); + const reference = createReference(); + + it('transforms reference strings to reference objects', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + // Create a loader that returns data with reference strings + const dogsLoader = { + name: 'dogs-loader', + load: async (context) => { + const data = { + id: 'beagle', + name: 'Beagle Dog', + favoriteCat: 'tabby', + }; + + const parsed = await context.parseData({ + id: 'beagle', + data, + }); + + await context.store.set({ + id: 'beagle', + data: parsed, + }); + }, + }; + + const collections = { + dogs: defineCollection({ + loader: dogsLoader, + schema: z.object({ + id: z.string(), + name: z.string(), + favoriteCat: reference('cats'), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result = store.get('dogs', 'beagle'); + assert.ok(result); + assert.equal(result.data.id, 'beagle'); + assert.equal(result.data.name, 'Beagle Dog'); + assert.deepEqual(result.data.favoriteCat, { collection: 'cats', id: 'tabby' }); + }); + + it('transforms dates correctly', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const eventsLoader = { + name: 'events-loader', + load: async (context) => { + const data = { + id: 'event1', + title: 'Launch Event', + publishedDate: '2024-07-20', + eventTime: '2024-07-20T10:00:00Z', + }; + + const parsed = await context.parseData({ + id: 'event1', + data, + }); + + await context.store.set({ + id: 'event1', + data: parsed, + }); + }, + }; + + const collections = { + events: defineCollection({ + loader: eventsLoader, + schema: z.object({ + id: z.string(), + title: z.string(), + publishedDate: z.coerce.date(), + eventTime: z.coerce.date(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result = store.get('events', 'event1'); + assert.ok(result); + assert.ok(result.data.publishedDate instanceof Date); + assert.ok(result.data.eventTime instanceof Date); + assert.equal(result.data.publishedDate.toISOString().split('T')[0], '2024-07-20'); + assert.equal(result.data.eventTime.toISOString(), '2024-07-20T10:00:00.000Z'); + }); + + it('applies schema defaults', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const productsLoader = { + name: 'products-loader', + load: async (context) => { + const data = { + id: 'product1', + name: 'Basic Product', + // Missing inStock, category, and tags - should use defaults + }; + + const parsed = await context.parseData({ + id: 'product1', + data, + }); + + await context.store.set({ + id: 'product1', + data: parsed, + }); + }, + }; + + const collections = { + products: defineCollection({ + loader: productsLoader, + schema: z.object({ + id: z.string(), + name: z.string(), + inStock: z.boolean().default(false), + category: z.string().default('uncategorized'), + tags: z.array(z.string()).default([]), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result = store.get('products', 'product1'); + assert.ok(result); + assert.equal(result.data.inStock, false); + assert.equal(result.data.category, 'uncategorized'); + assert.deepEqual(result.data.tags, []); + }); + + it('handles array of references', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const teamsLoader = { + name: 'teams-loader', + load: async (context) => { + const data = { + id: 'team1', + name: 'Rocket Team', + members: ['john', 'jane', 'bob'], + }; + + const parsed = await context.parseData({ + id: 'team1', + data, + }); + + await context.store.set({ + id: 'team1', + data: parsed, + }); + }, + }; + + const collections = { + teams: defineCollection({ + loader: teamsLoader, + schema: z.object({ + id: z.string(), + name: z.string(), + members: z.array(reference('people')), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result = store.get('teams', 'team1'); + assert.ok(result); + assert.equal(result.data.members.length, 3); + assert.deepEqual(result.data.members[0], { collection: 'people', id: 'john' }); + assert.deepEqual(result.data.members[1], { collection: 'people', id: 'jane' }); + assert.deepEqual(result.data.members[2], { collection: 'people', id: 'bob' }); + }); + + it('validates and rejects invalid data', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const itemsLoader = { + name: 'items-loader', + load: async (context) => { + const data = { + id: 'invalid', + name: 'Test Item', + count: 'not-a-number', // Should be number + email: 'not-an-email', // Should be valid email + }; + + try { + const parsed = await context.parseData({ + id: 'invalid', + data, + }); + + await context.store.set({ + id: 'invalid', + data: parsed, + }); + } catch (error) { + // Store error info for testing + await context.store.set({ + id: 'error', + data: { + hasError: true, + errorMessage: error.message, + }, + }); + } + }, + }; + + const collections = { + items: defineCollection({ + loader: itemsLoader, + schema: z.object({ + id: z.string(), + name: z.string(), + count: z.number(), + email: z.string().email(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // The invalid entry should not be stored + const invalidEntry = store.get('items', 'invalid'); + assert.equal(invalidEntry, undefined); + + // Check if error was captured + const errorEntry = store.get('items', 'error'); + assert.ok(errorEntry); + assert.equal(errorEntry.data.hasError, true); + assert.ok(errorEntry.data.errorMessage.includes('data does not match collection schema')); + }); + + it('handles nested schemas with mixed transforms', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const articlesLoader = { + name: 'articles-loader', + load: async (context) => { + const data = { + id: 'complex', + metadata: { + created: '2024-01-01', + updated: '2024-01-15T10:30:00Z', + author: 'john-doe', + }, + settings: { + isPublished: true, + // Missing priority - should use default + }, + }; + + const parsed = await context.parseData({ + id: 'complex', + data, + }); + + await context.store.set({ + id: 'complex', + data: parsed, + }); + }, + }; + + const collections = { + articles: defineCollection({ + loader: articlesLoader, + schema: z.object({ + id: z.string(), + metadata: z.object({ + created: z.coerce.date(), + updated: z.coerce.date(), + author: reference('authors'), + tags: z.array(z.string()).default([]), + }), + settings: z.object({ + isPublished: z.boolean().default(false), + priority: z.number().default(0), + }), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result = store.get('articles', 'complex'); + assert.ok(result); + assert.ok(result.data.metadata.created instanceof Date); + assert.ok(result.data.metadata.updated instanceof Date); + assert.deepEqual(result.data.metadata.author, { collection: 'authors', id: 'john-doe' }); + assert.deepEqual(result.data.metadata.tags, []); // default empty array + assert.equal(result.data.settings.isPublished, true); + assert.equal(result.data.settings.priority, 0); // default value + }); + + it('handles optional fields correctly', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const minimalProductLoader = { + name: 'minimal-product-loader', + load: async (context) => { + const data = { + id: 'minimal', + name: 'Minimal Product', + // All optional fields omitted + }; + + const parsed = await context.parseData({ + id: 'minimal', + data, + }); + + await context.store.set({ + id: 'minimal', + data: parsed, + }); + }, + }; + + const collections = { + products: defineCollection({ + loader: minimalProductLoader, + schema: z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + price: z.number().optional(), + relatedProduct: reference('products').optional(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result = store.get('products', 'minimal'); + assert.ok(result); + assert.equal(result.data.description, undefined); + assert.equal(result.data.price, undefined); + assert.equal(result.data.relatedProduct, undefined); + }); + + it('transforms reference with default value', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const itemsLoader = { + name: 'items-loader', + load: async (context) => { + // Load two items - one with category, one without + const items = [ + { + id: 'item1', + name: 'Item with category', + category: 'electronics', + }, + { + id: 'item2', + name: 'Item without category', + // No category specified - should use default + }, + ]; + + for (const item of items) { + const parsed = await context.parseData({ + id: item.id, + data: item, + }); + + await context.store.set({ + id: item.id, + data: parsed, + }); + } + }, + }; + + const collections = { + items: defineCollection({ + loader: itemsLoader, + schema: z.object({ + id: z.string(), + name: z.string(), + category: reference('categories').default('general'), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result1 = store.get('items', 'item1'); + assert.deepEqual(result1.data.category, { collection: 'categories', id: 'electronics' }); + + const result2 = store.get('items', 'item2'); + // The default is applied as a string, not transformed to a reference object + assert.equal(result2.data.category, 'general'); + }); +}); diff --git a/packages/astro/test/units/content-layer/file-loader.test.js b/packages/astro/test/units/content-layer/file-loader.test.js new file mode 100644 index 000000000000..5f1add6e6a1d --- /dev/null +++ b/packages/astro/test/units/content-layer/file-loader.test.js @@ -0,0 +1,293 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { z } from 'zod'; +import { file } from '../../../dist/content/loaders/file.js'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { Logger } from '../../../dist/core/logger/core.js'; +import { createTestConfigObserver, createMinimalSettings } from './test-helpers.js'; + +describe('File Loader', () => { + const root = new URL('../../fixtures/content-layer/', import.meta.url); + + it('loads entries from JSON file', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const collections = { + dogs: defineCollection({ + loader: file('src/data/dogs.json'), + schema: z.object({ + breed: z.string(), + id: z.string(), + size: z.string(), + origin: z.string(), + lifespan: z.string(), + temperament: z.array(z.string()), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check that entries were loaded + const entries = store.values('dogs'); + assert.equal(entries.length, 25); + + // Check a specific entry + const beagle = entries.find((e) => e.id === 'beagle'); + assert.ok(beagle); + assert.equal(beagle.data.breed, 'Beagle'); + assert.deepEqual(beagle.data.temperament, ['Friendly', 'Curious', 'Merry']); + assert.equal(beagle.filePath, 'src/data/dogs.json'); + }); + + it('loads entries from YAML file', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const collections = { + fish: defineCollection({ + loader: file('src/data/fish.yaml'), + schema: z.object({ + name: z.string(), + breed: z.string(), + age: z.number(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('fish'); + assert.equal(entries.length, 10); + + const nemo = entries.find((e) => e.id === 'nemo'); + assert.ok(nemo); + assert.equal(nemo.data.name, 'Nemo'); + assert.equal(nemo.data.breed, 'Clownfish'); + assert.equal(nemo.data.age, 3); + }); + + it('loads entries from TOML file', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const collections = { + songs: defineCollection({ + loader: file('src/data/songs.toml'), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('songs'); + assert.equal(entries.length, 8); + + // Songs have 'name' and 'artists' fields + const crown = entries.find((e) => e.id === 'crown'); + assert.ok(crown); + assert.equal(crown.data.name, 'Crown'); + }); + + it('loads entries from CSV file with custom parser', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const collections = { + plants: defineCollection({ + loader: file('src/data/plants.csv', { + parser: (text) => { + const [headers, ...rows] = text.trim().split('\n'); + return rows.map((row) => + Object.fromEntries(headers.split(',').map((h, i) => [h, row.split(',')[i]])), + ); + }, + }), + schema: z.object({ + id: z.string(), + common_name: z.string(), + scientific_name: z.string(), + color: z.string(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('plants'); + assert.equal(entries.length, 10); + + const rose = entries.find((e) => e.id === 'rose'); + assert.ok(rose); + assert.equal(rose.data.common_name, 'Rose'); + assert.equal(rose.data.scientific_name, 'Rosa'); + assert.equal(rose.data.color, 'Red'); + }); + + it('loads nested JSON with custom parser', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const collections = { + birds: defineCollection({ + loader: file('src/data/birds.json', { + parser: (text) => JSON.parse(text).birds, + }), + schema: z.object({ + id: z.string(), + name: z.string(), + breed: z.string(), + age: z.number(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('birds'); + assert.equal(entries.length, 5); + + const bluejay = entries.find((e) => e.id === 'bluejay'); + assert.ok(bluejay); + assert.equal(bluejay.data.name, 'Blue Jay'); + assert.equal(bluejay.data.breed, 'Cyanocitta cristata'); + assert.equal(bluejay.data.age, 3); + }); + + it('uses async parser', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const collections = { + birdsAsync: defineCollection({ + loader: file('src/data/birds.json', { + parser: async (text) => { + // Simulate async work + await new Promise((resolve) => setTimeout(resolve, 10)); + return JSON.parse(text).birds; + }, + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('birdsAsync'); + assert.equal(entries.length, 5); + }); + + it('warns on duplicate IDs', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + + // Create a custom logger to capture warnings + const warnings = []; + const logger = new Logger({ + dest: { + write: (msg) => { + if (msg.level === 'warn') { + warnings.push(msg.message); + } + return true; + }, + }, + level: 'info', + }); + + const collections = { + dogsWithDupes: defineCollection({ + loader: file('src/data/dogs.json', { + parser: () => [ + { id: 'beagle', breed: 'Beagle 1' }, + { id: 'beagle', breed: 'Beagle 2' }, + ], + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check that a warning was logged + assert.ok(warnings.some((w) => w.includes('Duplicate id "beagle"'))); + + // Check that the last entry won + const entries = store.values('dogsWithDupes'); + assert.equal(entries.length, 1); + assert.equal(entries[0].data.breed, 'Beagle 2'); + }); +}); diff --git a/packages/astro/test/units/content-layer/glob-loader.test.js b/packages/astro/test/units/content-layer/glob-loader.test.js new file mode 100644 index 000000000000..4fc4892ab3a4 --- /dev/null +++ b/packages/astro/test/units/content-layer/glob-loader.test.js @@ -0,0 +1,351 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { glob } from '../../../dist/content/loaders/glob.js'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { Logger } from '../../../dist/core/logger/core.js'; +import { + createTestConfigObserver, + createMinimalSettings, + createMarkdownEntryType, +} from './test-helpers.js'; + +describe('Glob Loader', () => { + const root = new URL('../../fixtures/content-layer/', import.meta.url); + + it('loads markdown files with glob pattern', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root, { + contentEntryTypes: [createMarkdownEntryType()], + }); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const collections = { + spacecraft: defineCollection({ + loader: glob({ pattern: '*.md', base: 'src/content/space' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('spacecraft'); + assert.ok(entries.length > 0); + + // Check that columbia exists + const columbia = entries.find((e) => e.id === 'columbia'); + assert.ok(columbia); + assert.ok(columbia.body); + assert.ok(columbia.body.includes('Space Shuttle Columbia')); + assert.equal(columbia.filePath.replace(/\\/g, '/'), 'src/content/space/columbia.md'); + }); + + it('handles negative matches in glob pattern', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root, { + contentEntryTypes: [createMarkdownEntryType()], + }); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const collections = { + probes: defineCollection({ + loader: glob({ pattern: ['*.md', '!voyager-*'], base: 'src/data/space-probes' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('probes'); + assert.equal(entries.length, 6); + + // Verify voyager probes are excluded + assert.ok(entries.every((e) => !e.id.startsWith('voyager'))); + + // Check that other probes exist + const cassini = entries.find((e) => e.id === 'cassini'); + assert.ok(cassini); + }); + + it('retains body by default', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root, { + contentEntryTypes: [createMarkdownEntryType()], + }); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const collections = { + spacecraftWithBody: defineCollection({ + loader: glob({ pattern: '*.md', base: 'src/content/space' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('spacecraftWithBody'); + assert.ok(entries.length > 0); + + const entry = entries[0]; + assert.ok(entry.body); + assert.ok(entry.body.length > 0); + }); + + it('clears body when retainBody is false', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root, { + contentEntryTypes: [createMarkdownEntryType()], + }); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const collections = { + spacecraftNoBody: defineCollection({ + loader: glob({ pattern: '*.md', base: 'src/content/space', retainBody: false }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('spacecraftNoBody'); + assert.ok(entries.length > 0); + + const entry = entries[0]; + assert.equal(entry.body, undefined); + }); + + it('loads YAML files with glob pattern', async () => { + const store = new MutableDataStore(); + + // Create custom YAML data entry type + const yamlEntryType = { + extensions: ['.yaml', '.yml'], + getEntryInfo: ({ contents }) => { + // Simple YAML parser + const lines = contents.trim().split('\n'); + const data = {}; + lines.forEach((line) => { + const colonIndex = line.indexOf(':'); + if (colonIndex > -1) { + const key = line.substring(0, colonIndex).trim(); + const value = line + .substring(colonIndex + 1) + .trim() + .replace(/["']/g, ''); + data[key] = value; + } + }); + return { data, body: '', slug: '' }; + }, + }; + + const settings = createMinimalSettings(root, { + config: { + root, + srcDir: new URL('./src/', root), + }, + dataEntryTypes: [yamlEntryType], + }); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const collections = { + numbersYaml: defineCollection({ + loader: glob({ pattern: 'src/data/glob-yaml/*', base: '.' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('numbersYaml'); + assert.equal(entries.length, 3); + + const ids = entries.map((e) => e.id).sort(); + // The glob loader includes the path in the ID + assert.deepEqual(ids, [ + 'src/data/glob-yaml/one', + 'src/data/glob-yaml/three', + 'src/data/glob-yaml/two', + ]); + }); + + it('loads TOML files with glob pattern', async () => { + const store = new MutableDataStore(); + + // Create custom TOML data entry type + const tomlEntryType = { + extensions: ['.toml'], + getEntryInfo: ({ contents }) => { + // Simple TOML parser for key-value pairs + const lines = contents.trim().split('\n'); + const data = {}; + lines.forEach((line) => { + const equalIndex = line.indexOf('='); + if (equalIndex > -1) { + const key = line.substring(0, equalIndex).trim(); + const value = line + .substring(equalIndex + 1) + .trim() + .replace(/["']/g, ''); + data[key] = value; + } + }); + return { data, body: '', slug: '' }; + }, + }; + + const settings = createMinimalSettings(root, { + config: { + root, + srcDir: new URL('./src/', root), + }, + dataEntryTypes: [tomlEntryType], + }); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const collections = { + numbersToml: defineCollection({ + loader: glob({ pattern: 'src/data/glob-toml/*', base: '.' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('numbersToml'); + assert.equal(entries.length, 3); + + const ids = entries.map((e) => e.id).sort(); + // The glob loader includes the path in the ID + assert.deepEqual(ids, [ + 'src/data/glob-toml/one', + 'src/data/glob-toml/three', + 'src/data/glob-toml/two', + ]); + }); + + it('warns about missing directory', async () => { + const store = new MutableDataStore(); + const warnings = []; + const logger = new Logger({ + dest: { + write: (msg) => { + if (msg.level === 'warn') { + warnings.push(msg.message); + } + return true; + }, + }, + level: 'info', + }); + + const settings = createMinimalSettings(root); + + const collections = { + notADirectory: defineCollection({ + loader: glob({ pattern: '*', base: 'src/nonexistent' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + assert.ok(warnings.some((w) => w.includes('does not exist'))); + }); + + it('warns about no matching files', async () => { + const store = new MutableDataStore(); + const warnings = []; + const logger = new Logger({ + dest: { + write: (msg) => { + if (msg.level === 'warn') { + warnings.push(msg.message); + } + return true; + }, + }, + level: 'info', + }); + + const settings = createMinimalSettings(root); + + const collections = { + nothingMatches: defineCollection({ + loader: glob({ pattern: 'nothingmatches/*', base: 'src/data' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + assert.ok(warnings.some((w) => w.includes('No files found matching'))); + }); +}); diff --git a/packages/astro/test/units/content-layer/live-loaders.test.js b/packages/astro/test/units/content-layer/live-loaders.test.js new file mode 100644 index 000000000000..3413cca5cfc8 --- /dev/null +++ b/packages/astro/test/units/content-layer/live-loaders.test.js @@ -0,0 +1,669 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { z } from 'zod'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { Logger } from '../../../dist/core/logger/core.js'; +import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.js'; + +describe('Content Layer - Live Loaders', () => { + const root = createTempDir(); + + it('loads initial data through sync', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + // Define test data + const entries = { + 123: { + id: '123', + data: { title: 'Page 123', age: 10 }, + rendered: { html: '

Page 123

This is rendered content.

' }, + }, + 456: { + id: '456', + data: { title: 'Page 456', age: 20 }, + }, + 789: { + id: '789', + data: { title: 'Page 789', age: 30 }, + }, + }; + + // Create a live loader + const testLoader = { + name: 'test-loader', + load: async (context) => { + // Sync loader that loads initial data + for (const entry of Object.values(entries)) { + const parsed = await context.parseData({ + id: entry.id, + data: entry.data, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + rendered: entry.rendered, + }); + } + }, + }; + + const collections = { + liveStuff: defineCollection({ + loader: testLoader, + schema: z.object({ + title: z.string(), + age: z.number(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify initial data was loaded + const allEntries = store.values('liveStuff'); + assert.equal(allEntries.length, 3); + + // Check individual entries + const entry1 = store.get('liveStuff', '123'); + assert.ok(entry1); + assert.equal(entry1.data.title, 'Page 123'); + assert.equal(entry1.data.age, 10); + assert.ok(entry1.rendered); + assert.equal(entry1.rendered.html, '

Page 123

This is rendered content.

'); + + const entry2 = store.get('liveStuff', '456'); + assert.ok(entry2); + assert.equal(entry2.data.title, 'Page 456'); + assert.equal(entry2.data.age, 20); + assert.ok(!entry2.rendered); // No rendered content for this entry + + const entry3 = store.get('liveStuff', '789'); + assert.ok(entry3); + assert.equal(entry3.data.title, 'Page 789'); + assert.equal(entry3.data.age, 30); + }); + + it('simulates live loader with loadEntry functionality', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + // Mock data source + const dataSource = { + 123: { id: '123', data: { title: 'Page 123', age: 10 } }, + 456: { id: '456', data: { title: 'Page 456', age: 20 } }, + }; + + // Loader that simulates live loading behavior + const liveSimulationLoader = { + name: 'live-simulation-loader', + load: async (context) => { + // Initial load - only load entry 123 + const entry = dataSource['123']; + const parsed = await context.parseData({ + id: entry.id, + data: entry.data, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + + // Store metadata about what would be available for live loading + await context.store.set({ + id: '_meta', + data: { + availableIds: Object.keys(dataSource), + supportsLiveLoading: true, + }, + }); + }, + }; + + const collections = { + liveSimulation: defineCollection({ + loader: liveSimulationLoader, + schema: z.object({ + title: z.string(), + age: z.number(), + availableIds: z.array(z.string()).optional(), + supportsLiveLoading: z.boolean().optional(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check initial state + const entry123 = store.get('liveSimulation', '123'); + assert.ok(entry123); + assert.equal(entry123.data.title, 'Page 123'); + + // Entry 456 would not be loaded initially + const entry456 = store.get('liveSimulation', '456'); + assert.ok(!entry456); + + // Check metadata + const meta = store.get('liveSimulation', '_meta'); + assert.ok(meta); + assert.deepEqual(meta.data.availableIds, ['123', '456']); + assert.equal(meta.data.supportsLiveLoading, true); + }); + + it('demonstrates dynamic data transformation', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + // Loader that transforms data based on context + const transformLoader = { + name: 'transform-loader', + load: async (context) => { + const entries = [ + { id: '1', data: { title: 'Entry 1', value: 10, category: 'A' } }, + { id: '2', data: { title: 'Entry 2', value: 20, category: 'B' } }, + { id: '3', data: { title: 'Entry 3', value: 30, category: 'A' } }, + ]; + + for (const entry of entries) { + // Apply transformations + const transformedData = { + ...entry.data, + // Add computed fields + doubled: entry.data.value * 2, + categoryLabel: `Category ${entry.data.category}`, + timestamp: new Date('2025-01-01T00:00:00.000Z'), + }; + + const parsed = await context.parseData({ + id: entry.id, + data: transformedData, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } + }, + }; + + const collections = { + transformed: defineCollection({ + loader: transformLoader, + schema: z.object({ + title: z.string(), + value: z.number(), + category: z.string(), + doubled: z.number(), + categoryLabel: z.string(), + timestamp: z.date(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify transformations + const entry1 = store.get('transformed', '1'); + assert.ok(entry1); + assert.equal(entry1.data.doubled, 20); + assert.equal(entry1.data.categoryLabel, 'Category A'); + + const entry2 = store.get('transformed', '2'); + assert.ok(entry2); + assert.equal(entry2.data.doubled, 40); + assert.equal(entry2.data.categoryLabel, 'Category B'); + }); + + it('handles loader errors gracefully', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + // Loader that simulates error conditions + const errorProneLoader = { + name: 'error-prone-loader', + load: async (context) => { + // Add some valid entries + await context.store.set({ + id: 'valid-1', + data: { title: 'Valid Entry 1', status: 'ok' }, + }); + + // Try to parse invalid data - this should be caught by schema validation + try { + const parsed = await context.parseData({ + id: 'invalid-1', + data: { title: 123, status: 'invalid' }, // title should be string + }); + await context.store.set({ + id: 'invalid-1', + data: parsed, + }); + } catch (error) { + // Store error information + await context.store.set({ + id: 'error-log', + data: { + title: 'Error Log', + status: 'error', + errorMessage: error.message, + }, + }); + } + }, + }; + + const collections = { + errorProne: defineCollection({ + loader: errorProneLoader, + schema: z.object({ + title: z.string(), + status: z.string(), + errorMessage: z.string().optional(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check valid entry + const validEntry = store.get('errorProne', 'valid-1'); + assert.ok(validEntry); + assert.equal(validEntry.data.title, 'Valid Entry 1'); + assert.equal(validEntry.data.status, 'ok'); + + // Check that invalid entry was not stored + const invalidEntry = store.get('errorProne', 'invalid-1'); + assert.ok(!invalidEntry); + + // Check error log + const errorLog = store.get('errorProne', 'error-log'); + assert.ok(errorLog); + assert.ok(errorLog.data.errorMessage); + }); + + it('supports complex rendered content', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const renderedContentLoader = { + name: 'rendered-content-loader', + load: async (context) => { + const articles = [ + { + id: 'article-1', + data: { + title: 'First Article', + author: 'John Doe', + publishDate: new Date('2025-01-15'), + }, + content: + '# First Article\n\nThis is the **first** article with [links](https://example.com).', + }, + { + id: 'article-2', + data: { + title: 'Second Article', + author: 'Jane Smith', + publishDate: new Date('2025-01-20'), + }, + content: + '## Second Article\n\nThis article has:\n- Lists\n- Code blocks\n\n```js\nconsole.log("Hello");\n```', + }, + ]; + + for (const article of articles) { + // Simulate rendering process + const rendered = await context.renderMarkdown(article.content, { + fileURL: new URL(`${article.id}.md`, root), + }); + + const parsed = await context.parseData({ + id: article.id, + data: article.data, + }); + + await context.store.set({ + id: article.id, + data: parsed, + body: article.content, + rendered: { + html: rendered.html, + metadata: { + ...rendered.metadata, + wordCount: article.content.split(/\s+/).length, + readingTime: Math.ceil(article.content.split(/\s+/).length / 200), + }, + }, + }); + } + }, + }; + + const collections = { + articles: defineCollection({ + loader: renderedContentLoader, + schema: z.object({ + title: z.string(), + author: z.string(), + publishDate: z.date(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check first article + const article1 = store.get('articles', 'article-1'); + assert.ok(article1); + assert.equal(article1.data.title, 'First Article'); + assert.ok(article1.body); + assert.ok(article1.rendered); + assert.ok(article1.rendered.html); + assert.ok(article1.rendered.html.includes('First Article')); + assert.ok(article1.rendered.metadata); + assert.ok(article1.rendered.metadata.wordCount > 0); + + // Check second article + const article2 = store.get('articles', 'article-2'); + assert.ok(article2); + assert.ok(article2.rendered); + // Check for code block rendering + assert.ok(article2.rendered.html.includes(' { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const cacheAwareLoader = { + name: 'cache-aware-loader', + load: async (context) => { + const now = new Date(); + const entries = [ + { + id: 'static-content', + data: { + title: 'Static Page', + type: 'static', + content: 'This content rarely changes', + }, + cache: { + lastModified: new Date('2024-01-01'), + maxAge: 86400 * 30, // 30 days + tags: ['static', 'page'], + }, + }, + { + id: 'dynamic-content', + data: { + title: 'Dynamic Dashboard', + type: 'dynamic', + content: 'This updates frequently', + }, + cache: { + lastModified: now, + maxAge: 300, // 5 minutes + tags: ['dynamic', 'dashboard', 'realtime'], + }, + }, + { + id: 'user-content', + data: { + title: 'User Profile', + type: 'personalized', + content: 'User-specific content', + }, + cache: { + lastModified: now, + maxAge: 0, // No caching + tags: ['user', 'personalized', 'no-cache'], + }, + }, + ]; + + for (const entry of entries) { + const parsed = await context.parseData({ + id: entry.id, + data: { + ...entry.data, + cacheInfo: { + maxAge: entry.cache.maxAge, + tags: entry.cache.tags, + lastModified: entry.cache.lastModified.toISOString(), + }, + }, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } + }, + }; + + const collections = { + cached: defineCollection({ + loader: cacheAwareLoader, + schema: z.object({ + title: z.string(), + type: z.string(), + content: z.string(), + cacheInfo: z.object({ + maxAge: z.number(), + tags: z.array(z.string()), + lastModified: z.string(), + }), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify static content caching + const staticContent = store.get('cached', 'static-content'); + assert.ok(staticContent); + assert.equal(staticContent.data.cacheInfo.maxAge, 86400 * 30); + assert.ok(staticContent.data.cacheInfo.tags.includes('static')); + + // Verify dynamic content caching + const dynamicContent = store.get('cached', 'dynamic-content'); + assert.ok(dynamicContent); + assert.equal(dynamicContent.data.cacheInfo.maxAge, 300); + assert.ok(dynamicContent.data.cacheInfo.tags.includes('realtime')); + + // Verify personalized content caching + const userContent = store.get('cached', 'user-content'); + assert.ok(userContent); + assert.equal(userContent.data.cacheInfo.maxAge, 0); + assert.ok(userContent.data.cacheInfo.tags.includes('no-cache')); + }); + + it('validates schema during data loading', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const validationLoader = { + name: 'validation-loader', + load: async (context) => { + const testData = [ + // Valid entries + { id: 'valid-1', data: { name: 'Alice', age: 30, email: 'alice@example.com' } }, + { id: 'valid-2', data: { name: 'Bob', age: 25, email: 'bob@example.com' } }, + // Invalid entries (these will fail schema validation) + { id: 'invalid-age', data: { name: 'Charlie', age: -5, email: 'charlie@example.com' } }, + { id: 'invalid-email', data: { name: 'David', age: 35, email: 'not-an-email' } }, + { id: 'missing-field', data: { name: 'Eve', age: 28 } }, // missing email + ]; + + let successCount = 0; + let errorCount = 0; + + for (const item of testData) { + try { + const parsed = await context.parseData({ + id: item.id, + data: item.data, + }); + + await context.store.set({ + id: item.id, + data: parsed, + }); + successCount++; + } catch (_error) { + errorCount++; + // Optionally store validation errors + if (item.id.startsWith('invalid')) { + await context.store.set({ + id: `${item.id}-error`, + data: { + name: `Error for ${item.data.name}`, + age: 0, + email: 'error@example.com', + validationError: true, + }, + }); + } + } + } + + // Store summary + await context.store.set({ + id: '_validation_summary', + data: { + name: 'Validation Summary', + age: 0, + email: 'summary@example.com', + successCount, + errorCount, + }, + }); + }, + }; + + const collections = { + validated: defineCollection({ + loader: validationLoader, + schema: z.object({ + name: z.string().min(1), + age: z.number().positive(), + email: z.string().email(), + validationError: z.boolean().optional(), + successCount: z.number().optional(), + errorCount: z.number().optional(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check valid entries + const valid1 = store.get('validated', 'valid-1'); + assert.ok(valid1); + assert.equal(valid1.data.name, 'Alice'); + assert.equal(valid1.data.age, 30); + + const valid2 = store.get('validated', 'valid-2'); + assert.ok(valid2); + assert.equal(valid2.data.name, 'Bob'); + + // Check that invalid entries were not stored + const invalidAge = store.get('validated', 'invalid-age'); + assert.ok(!invalidAge); + + const invalidEmail = store.get('validated', 'invalid-email'); + assert.ok(!invalidEmail); + + const missingField = store.get('validated', 'missing-field'); + assert.ok(!missingField); + + // Check summary + const summary = store.get('validated', '_validation_summary'); + assert.ok(summary); + assert.equal(summary.data.successCount, 2); // Only valid-1 and valid-2 + assert.equal(summary.data.errorCount, 3); // Three invalid entries + }); +}); diff --git a/packages/astro/test/units/content-layer/loader-warnings.test.js b/packages/astro/test/units/content-layer/loader-warnings.test.js new file mode 100644 index 000000000000..9408486619cd --- /dev/null +++ b/packages/astro/test/units/content-layer/loader-warnings.test.js @@ -0,0 +1,576 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { z } from 'zod'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { Logger } from '../../../dist/core/logger/core.js'; +import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.js'; +import { Writable } from 'node:stream'; +import fs from 'node:fs/promises'; + +describe('Content Layer - Loader Warnings', () => { + it('warns about missing data in loaders', async () => { + const root = createTempDir(); + const store = new MutableDataStore(); + const logs = []; + + const logger = new Logger({ + level: 'warn', + dest: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }); + + // Loader that simulates various warning scenarios + const warningLoader = { + name: 'warning-loader', + load: async (context) => { + // Warn about missing directory + context.logger.warn('Directory "src/content/non-existent-dir" does not exist'); + + // Add some valid entries + await context.store.set({ + id: 'valid-1', + data: { title: 'Valid Entry', status: 'ok' }, + }); + + // Try to add duplicate ID - this should be handled by the store + await context.store.set({ + id: 'duplicate-id', + data: { title: 'First Entry', value: 1 }, + }); + + // Second attempt with same ID (store will overwrite) + await context.store.set({ + id: 'duplicate-id', + data: { title: 'Second Entry', value: 2 }, + }); + + // Log warning about duplicate + context.logger.warn('Duplicate id "duplicate-id" found in collection'); + }, + }; + + const collections = { + warnings: defineCollection({ + loader: warningLoader, + schema: z.object({ + title: z.string(), + status: z.string().optional(), + value: z.number().optional(), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for warning logs + const missingDirWarning = logs.find( + (log) => log.level === 'warn' && log.message.includes('does not exist'), + ); + assert.ok(missingDirWarning, 'Should warn about missing directory'); + + const duplicateWarning = logs.find( + (log) => log.level === 'warn' && log.message.includes('Duplicate id'), + ); + assert.ok(duplicateWarning, 'Should warn about duplicate ID'); + + // Verify entries + const validEntry = store.get('warnings', 'valid-1'); + assert.ok(validEntry); + assert.equal(validEntry.data.title, 'Valid Entry'); + + // Duplicate ID should have the second entry's data (overwritten) + const duplicateEntry = store.get('warnings', 'duplicate-id'); + assert.ok(duplicateEntry); + assert.equal(duplicateEntry.data.title, 'Second Entry'); + assert.equal(duplicateEntry.data.value, 2); + }); + + it('warns about no files found in pattern matching', async () => { + const root = createTempDir(); + const store = new MutableDataStore(); + const logs = []; + + const logger = new Logger({ + level: 'warn', + dest: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }); + + // Create an empty directory + const emptyDir = new URL('./src/content/empty/', root); + await fs.mkdir(emptyDir, { recursive: true }); + + // Loader that simulates glob pattern with no matches + const emptyPatternLoader = { + name: 'empty-pattern-loader', + load: async (context) => { + // Simulate checking for files and finding none + const pattern = '*.mdx'; + const base = 'src/content/empty'; + + context.logger.warn(`No files found matching pattern "${pattern}" in "${base}"`); + + // Store metadata about the empty result + await context.store.set({ + id: '_meta', + data: { + pattern, + base, + filesFound: 0, + message: 'No matching files', + }, + }); + }, + }; + + const collections = { + emptyPattern: defineCollection({ + loader: emptyPatternLoader, + schema: z.object({ + pattern: z.string().optional(), + base: z.string().optional(), + filesFound: z.number().optional(), + message: z.string().optional(), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for warning + const noFilesWarning = logs.find( + (log) => log.level === 'warn' && log.message.includes('No files found matching'), + ); + assert.ok(noFilesWarning, 'Should warn about no files found'); + + // Check metadata + const meta = store.get('emptyPattern', '_meta'); + assert.ok(meta); + assert.equal(meta.data.filesFound, 0); + }); + + it('handles validation errors gracefully', async () => { + const root = createTempDir(); + const store = new MutableDataStore(); + const logs = []; + + const logger = new Logger({ + level: 'error', + dest: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }); + + // Loader that produces validation errors + const validationErrorLoader = { + name: 'validation-error-loader', + load: async (context) => { + const testData = [ + { id: 'item1', name: 'Valid Item', count: 5 }, + { id: 'item2', count: 10 }, // Missing required 'name' + { name: 'No ID Item', count: 15 }, // Would fail if 'id' is required by schema + { id: 'item3', name: 'Invalid Count', count: 'not-a-number' }, // Wrong type + ]; + + let successCount = 0; + let errorCount = 0; + + for (const item of testData) { + try { + const parsed = await context.parseData({ + id: item.id || 'generated-' + Date.now(), + data: item, + }); + + await context.store.set({ + id: item.id || 'generated-' + Date.now(), + data: parsed, + }); + successCount++; + } catch (error) { + errorCount++; + context.logger.error(`Validation failed for ${item.id || 'unknown'}: ${error.message}`); + } + } + + // Store summary + await context.store.set({ + id: '_summary', + data: { + name: 'Validation Summary', + count: 0, + validationStats: { + success: successCount, + errors: errorCount, + }, + }, + }); + }, + }; + + const collections = { + validated: defineCollection({ + loader: validationErrorLoader, + schema: z.object({ + name: z.string(), + count: z.number(), + validationStats: z + .object({ + success: z.number(), + errors: z.number(), + }) + .optional(), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for validation error logs + const validationErrors = logs.filter( + (log) => log.level === 'error' && log.message.includes('Validation failed'), + ); + assert.ok(validationErrors.length > 0, 'Should log validation errors'); + + // Check valid entry + const validEntry = store.get('validated', 'item1'); + assert.ok(validEntry); + assert.equal(validEntry.data.name, 'Valid Item'); + assert.equal(validEntry.data.count, 5); + + // Check summary + const summary = store.get('validated', '_summary'); + assert.ok(summary); + assert.ok(summary.data.validationStats.errors > 0); + }); + + it('handles malformed data gracefully', async () => { + const root = createTempDir(); + const store = new MutableDataStore(); + const logs = []; + + const logger = new Logger({ + level: 'error', + dest: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }); + + // Loader that simulates processing malformed data + const malformedDataLoader = { + name: 'malformed-data-loader', + load: async (context) => { + // Simulate trying to parse malformed JSON + const malformedJson = '{ "id": "test", "name": "Missing closing brace"'; + + try { + // This would throw + const data = JSON.parse(malformedJson); + await context.store.set({ + id: 'should-not-exist', + data, + }); + } catch (error) { + context.logger.error(`Failed to parse JSON: ${error.message}`); + + // Store error info + await context.store.set({ + id: 'parse-error', + data: { + error: 'JSON Parse Error', + message: error.message, + recovered: true, + }, + }); + } + + // Add a valid entry to show the loader can continue + await context.store.set({ + id: 'valid-after-error', + data: { + error: 'None', + message: 'Successfully loaded after error', + recovered: true, + }, + }); + }, + }; + + const collections = { + malformed: defineCollection({ + loader: malformedDataLoader, + schema: z.object({ + error: z.string(), + message: z.string(), + recovered: z.boolean(), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for JSON error log + const jsonError = logs.find((log) => log.level === 'error' && log.message.includes('JSON')); + assert.ok(jsonError, 'Should log JSON parse error'); + + // Check that error was handled + const errorEntry = store.get('malformed', 'parse-error'); + assert.ok(errorEntry); + assert.equal(errorEntry.data.error, 'JSON Parse Error'); + assert.ok(errorEntry.data.recovered); + + // Check that loader continued after error + const validEntry = store.get('malformed', 'valid-after-error'); + assert.ok(validEntry); + assert.equal(validEntry.data.error, 'None'); + }); + + it('warns about duplicate IDs across multiple entries', async () => { + const root = createTempDir(); + const store = new MutableDataStore(); + const logs = []; + + const logger = new Logger({ + level: 'warn', + dest: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }); + + // Create data directory with test files + const dataDir = new URL('./src/data/', root); + await fs.mkdir(dataDir, { recursive: true }); + + // Write a JSON file with duplicate IDs + await fs.writeFile( + new URL('./dogs.json', dataDir), + JSON.stringify([ + { id: 'german-shepherd', breed: 'German Shepherd', size: 'Large' }, + { id: 'beagle', breed: 'Beagle', size: 'Small' }, + { id: 'german-shepherd', breed: 'German Shepherd Mix', size: 'Medium' }, // Duplicate + ]), + ); + + // Loader that processes array data and warns about duplicates + const duplicateCheckLoader = { + name: 'duplicate-check-loader', + load: async (context) => { + // Read and parse the file + const filePath = new URL('./dogs.json', dataDir); + const content = await fs.readFile(filePath, 'utf-8'); + const dogs = JSON.parse(content); + + const seenIds = new Set(); + + for (const dog of dogs) { + if (seenIds.has(dog.id)) { + context.logger.warn(`Duplicate id "${dog.id}" found in src/data/dogs.json`); + } + seenIds.add(dog.id); + + const parsed = await context.parseData({ + id: dog.id, + data: dog, + }); + + // Store will overwrite duplicates + await context.store.set({ + id: dog.id, + data: parsed, + }); + } + }, + }; + + const collections = { + dogs: defineCollection({ + loader: duplicateCheckLoader, + schema: z.object({ + id: z.string(), + breed: z.string(), + size: z.string(), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for duplicate warning + const duplicateWarning = logs.find( + (log) => + log.level === 'warn' && + log.message.includes('Duplicate id "german-shepherd"') && + log.message.includes('dogs.json'), + ); + assert.ok(duplicateWarning, 'Should warn about duplicate ID'); + + // Check entries - last duplicate wins + const entries = store.values('dogs'); + assert.equal(entries.length, 2); // Only 2 unique IDs + + const germanShepherd = store.get('dogs', 'german-shepherd'); + assert.ok(germanShepherd); + assert.equal(germanShepherd.data.breed, 'German Shepherd Mix'); // Last one wins + assert.equal(germanShepherd.data.size, 'Medium'); + }); + + it('handles missing required fields with helpful errors', async () => { + const root = createTempDir(); + const store = new MutableDataStore(); + const logs = []; + + const logger = new Logger({ + level: 'error', + dest: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }); + + // Loader with strict schema validation + const strictSchemaLoader = { + name: 'strict-schema-loader', + load: async (context) => { + const items = [ + { id: 'complete', title: 'Complete Item', priority: 'high', tags: ['important'] }, + { id: 'missing-title', priority: 'low', tags: [] }, // Missing required title + { id: 'missing-priority', title: 'No Priority' }, // Missing required priority + { id: 'invalid-tags', title: 'Bad Tags', priority: 'medium', tags: 'not-an-array' }, // Wrong type + ]; + + for (const item of items) { + try { + const parsed = await context.parseData({ + id: item.id, + data: item, + }); + + await context.store.set({ + id: item.id, + data: parsed, + }); + } catch (error) { + // Log detailed validation error + const issues = error.errors || []; + const fields = issues.map((issue) => issue.path.join('.')).join(', '); + context.logger.error( + `Validation failed for item "${item.id}": Missing or invalid fields: ${fields || error.message}`, + ); + } + } + }, + }; + + const collections = { + strictItems: defineCollection({ + loader: strictSchemaLoader, + schema: z.object({ + title: z.string(), + priority: z.enum(['low', 'medium', 'high']), + tags: z.array(z.string()).optional().default([]), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for specific validation errors + const validationLogs = logs.filter( + (log) => log.level === 'error' && log.message.includes('Validation failed'), + ); + assert.ok(validationLogs.length >= 2, 'Should have validation errors for invalid items'); + + // Only complete item should be stored + const completeItem = store.get('strictItems', 'complete'); + assert.ok(completeItem); + assert.equal(completeItem.data.title, 'Complete Item'); + assert.equal(completeItem.data.priority, 'high'); + assert.deepEqual(completeItem.data.tags, ['important']); + + // Invalid items should not be stored + assert.ok(!store.get('strictItems', 'missing-title')); + assert.ok(!store.get('strictItems', 'missing-priority')); + assert.ok(!store.get('strictItems', 'invalid-tags')); + }); +}); diff --git a/packages/astro/test/units/content-layer/markdown-rendering.test.js b/packages/astro/test/units/content-layer/markdown-rendering.test.js new file mode 100644 index 000000000000..55455a355913 --- /dev/null +++ b/packages/astro/test/units/content-layer/markdown-rendering.test.js @@ -0,0 +1,677 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { Logger } from '../../../dist/core/logger/core.js'; +import { defineCollection } from '../../../dist/content/config.js'; +import { z } from 'zod'; +import { + createTempDir, + createTestConfigObserver, + createMinimalSettings, + parseSimpleMarkdownFrontmatter, +} from './test-helpers.js'; + +describe('Content Layer - Markdown Rendering', () => { + // Create a real temp directory for tests + const root = createTempDir(); + + it('renders markdown content through ContentLayer', async () => { + const store = new MutableDataStore(); + + // Inline loader with markdown content + const markdownLoader = { + name: 'test-markdown-loader', + load: async (context) => { + const posts = [ + { + id: 'post-1', + content: `--- +title: Test Post +description: This is a test post +tags: ["astro", "testing"] +publishedDate: 2024-01-15 +--- + +# Hello World + +This is the post content with **bold** and *italic* text.`, + }, + { + id: 'post-2', + content: `--- +title: Another Post +publishedDate: 2024-01-20 +--- + +## Another Title + +Content with [a link](https://astro.build).`, + }, + ]; + + for (const post of posts) { + // Parse the markdown content + const { data, body } = parseSimpleMarkdownFrontmatter(post.content, post.id); + + // Parse data through the schema + const parsedData = await context.parseData({ + id: post.id, + data, + }); + + await context.store.set({ + id: post.id, + data: parsedData, + body, + }); + } + }, + }; + + // Define collections + const collections = { + posts: defineCollection({ + loader: markdownLoader, + schema: z.object({ + title: z.string(), + description: z.string().optional(), + tags: z.array(z.string()).optional(), + publishedDate: z.coerce.date(), + }), + }), + }; + + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + // Create ContentLayer with test config observer + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // Sync content + await contentLayer.sync(); + + // Verify markdown was processed + const post1 = store.get('posts', 'post-1'); + assert.ok(post1); + assert.equal(post1.data.title, 'Test Post'); + assert.equal(post1.data.description, 'This is a test post'); + assert.deepEqual(post1.data.tags, ['astro', 'testing']); + assert.ok(post1.data.publishedDate instanceof Date); + assert.ok(post1.body); + assert.ok(post1.body.includes('# Hello World')); + + const post2 = store.get('posts', 'post-2'); + assert.ok(post2); + assert.equal(post2.data.title, 'Another Post'); + assert.ok(post2.data.publishedDate instanceof Date); + assert.ok(post2.body); + assert.ok(post2.body.includes('## Another Title')); + }); + + it('renders markdown content with loader renderMarkdown', async () => { + const store = new MutableDataStore(); + + // Custom loader that uses renderMarkdown + const customMarkdownLoader = { + name: 'custom-markdown-loader', + load: async (context) => { + const markdownContent = `--- +title: Rendered Post +author: Test Author +--- + +# Rendered Content + +This content is processed by the loader using renderMarkdown. + +- List item 1 +- List item 2`; + + // Use the renderMarkdown function from context + const rendered = await context.renderMarkdown(markdownContent, { + fileURL: new URL('test.md', root), + }); + + await context.store.set({ + id: 'rendered-post', + data: { + title: 'Rendered Post', + author: 'Test Author', + }, + body: markdownContent, + rendered: { + html: rendered.html, + metadata: rendered.metadata, + }, + }); + }, + }; + + const collections = { + custom: defineCollection({ + loader: customMarkdownLoader, + schema: z.object({ + title: z.string(), + author: z.string(), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check that markdown was rendered + const entry = store.get('custom', 'rendered-post'); + assert.ok(entry); + assert.ok(entry.rendered); + assert.ok(entry.rendered.html); + // Check for heading - might have id attribute + assert.ok( + entry.rendered.html.includes('Rendered Content') && entry.rendered.html.includes('h1'), + ); + // Check for list items + assert.ok(entry.rendered.html.includes('List item 1')); + assert.ok(entry.rendered.metadata); + }); + + it('preserves markdown headings metadata', async () => { + const store = new MutableDataStore(); + + const customLoader = { + name: 'headings-test-loader', + load: async (context) => { + const content = `--- +title: Headings Test +--- + +# Main Title + +Some intro text. + +## Section 1 + +Section 1 content. + +### Subsection 1.1 + +More details. + +## Section 2 + +Section 2 content.`; + + const rendered = await context.renderMarkdown(content); + + await context.store.set({ + id: 'headings-test', + data: { title: 'Headings Test' }, + rendered: { + html: rendered.html, + metadata: rendered.metadata, + }, + }); + }, + }; + + const collections = { + headings: defineCollection({ + loader: customLoader, + }), + }; + + const settings = createMinimalSettings(root); + + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry = store.get('headings', 'headings-test'); + assert.ok(entry); + assert.ok(entry.rendered); + assert.ok(entry.rendered.metadata); + assert.ok(entry.rendered.metadata.headings); + assert.ok(Array.isArray(entry.rendered.metadata.headings)); + + const headings = entry.rendered.metadata.headings; + assert.ok(headings.length >= 4); + + // Check heading structure + const h1 = headings.find((h) => h.depth === 1); + assert.ok(h1); + assert.equal(h1.text, 'Main Title'); + + const h2s = headings.filter((h) => h.depth === 2); + assert.ok(h2s.length >= 2); + }); + + it('handles markdown with no frontmatter', async () => { + const store = new MutableDataStore(); + + const noFrontmatterLoader = { + name: 'no-frontmatter-loader', + load: async (context) => { + const content = `# Just Markdown + +This file has no frontmatter, just content.`; + + // Parse content - should handle no frontmatter gracefully + const { data, body } = parseSimpleMarkdownFrontmatter(content, 'plain'); + + await context.store.set({ + id: 'plain', + data, + body, + }); + }, + }; + + const collections = { + noFrontmatter: defineCollection({ + loader: noFrontmatterLoader, + }), + }; + + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry = store.get('noFrontmatter', 'plain'); + assert.ok(entry); + assert.ok(entry.body); + assert.ok(entry.body.includes('# Just Markdown')); + assert.ok(entry.body.includes('This file has no frontmatter')); + }); + + it('handles complex markdown with code blocks', async () => { + const store = new MutableDataStore(); + + const customLoader = { + name: 'code-test-loader', + load: async (context) => { + const content = `--- +title: Code Examples +--- + +# Code Examples + +Here's some JavaScript: + +\`\`\`javascript +function hello(name) { + return \`Hello, \${name}!\`; +} +\`\`\` + +And some inline code: \`const x = 42\`.`; + + const rendered = await context.renderMarkdown(content); + + await context.store.set({ + id: 'code-test', + data: { title: 'Code Examples' }, + rendered: { + html: rendered.html, + metadata: rendered.metadata, + }, + }); + }, + }; + + const collections = { + code: defineCollection({ + loader: customLoader, + }), + }; + + const settings = createMinimalSettings(root, { + config: { + markdown: { + syntaxHighlight: 'shiki', + shikiConfig: { + theme: 'github-dark', + }, + }, + }, + }); + + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry = store.get('code', 'code-test'); + assert.ok(entry); + assert.ok(entry.rendered); + assert.ok(entry.rendered.html); + // Should have code block elements + assert.ok(entry.rendered.html.includes(' { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const frontmatterTestLoader = { + name: 'frontmatter-test-loader', + load: async (context) => { + const markdownWithFrontmatter = `--- +title: Test Post +description: A test post for renderMarkdown +tags: + - test + - markdown +--- + +# Hello World + +This is the body content. + +## Subheading + +More content here.`; + + const result = await context.renderMarkdown(markdownWithFrontmatter, { + fileURL: new URL('test.md', root), + }); + + // Store the frontmatter data that was parsed + const parsed = await context.parseData({ + id: 'frontmatter-test', + data: { + ...result.metadata.frontmatter, + rendered: true, + }, + }); + + await context.store.set({ + id: 'frontmatter-test', + data: parsed, + body: markdownWithFrontmatter, + }); + }, + }; + + const collections = { + frontmatterTest: defineCollection({ + loader: frontmatterTestLoader, + schema: z.object({ + title: z.string(), + description: z.string(), + tags: z.array(z.string()), + rendered: z.boolean(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry = store.get('frontmatterTest', 'frontmatter-test'); + assert.ok(entry); + assert.equal(entry.data.title, 'Test Post'); + assert.equal(entry.data.description, 'A test post for renderMarkdown'); + assert.deepEqual(entry.data.tags, ['test', 'markdown']); + }); + + it('renderMarkdown excludes frontmatter from HTML output through loader', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const htmlTestLoader = { + name: 'html-test-loader', + load: async (context) => { + const markdownWithFrontmatter = `--- +title: Test Post +--- + +# Hello World`; + + const result = await context.renderMarkdown(markdownWithFrontmatter, { + fileURL: new URL('test.md', root), + }); + + await context.store.set({ + id: 'html-test', + data: { + html: result.html, + title: result.metadata.frontmatter.title || 'No title', + }, + }); + }, + }; + + const collections = { + htmlTest: defineCollection({ + loader: htmlTestLoader, + schema: z.object({ + html: z.string(), + title: z.string(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry = store.get('htmlTest', 'html-test'); + assert.ok(entry); + // HTML should not contain frontmatter + assert.ok(!entry.data.html.includes('title:')); + assert.ok(!entry.data.html.includes('Test Post')); + // But should contain the rendered content + assert.ok(entry.data.html.includes('Hello World')); + // And we should have access to the frontmatter data separately + assert.equal(entry.data.title, 'Test Post'); + }); + + it('renderMarkdown extracts headings correctly through loader', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const headingsTestLoader = { + name: 'headings-test-loader', + load: async (context) => { + const markdown = `# Heading 1 +Some text + +## Heading 2 +More text + +### Heading 3 +Even more text + +## Another Heading 2`; + + const result = await context.renderMarkdown(markdown, { + fileURL: new URL('test.md', root), + }); + + // Extract heading information + const headings = result.metadata.headings.map((h) => ({ + depth: h.depth, + text: h.text, + })); + + await context.store.set({ + id: 'headings-test', + data: { + headingCount: result.metadata.headings.length, + headings: headings, + }, + }); + }, + }; + + const collections = { + headingsTest: defineCollection({ + loader: headingsTestLoader, + schema: z.object({ + headingCount: z.number(), + headings: z.array( + z.object({ + depth: z.number(), + text: z.string(), + }), + ), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry = store.get('headingsTest', 'headings-test'); + assert.ok(entry); + assert.equal(entry.data.headingCount, 4); + assert.deepEqual(entry.data.headings, [ + { depth: 1, text: 'Heading 1' }, + { depth: 2, text: 'Heading 2' }, + { depth: 3, text: 'Heading 3' }, + { depth: 2, text: 'Another Heading 2' }, + ]); + }); + + it('renderMarkdown resolves relative image paths through loader', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const imageTestLoader = { + name: 'image-test-loader', + load: async (context) => { + const markdownWithImage = `# Post with Image + +![Local image](./image.png) +![Remote image](https://example.com/image.png)`; + + const fileURL = new URL('./virtual-post.md', root); + const result = await context.renderMarkdown(markdownWithImage, { + fileURL, + }); + + await context.store.set({ + id: 'image-test', + data: { + localImages: result.metadata.localImagePaths || [], + remoteImages: result.metadata.remoteImagePaths || [], + hasImages: true, + }, + }); + }, + }; + + const collections = { + imageTest: defineCollection({ + loader: imageTestLoader, + schema: z.object({ + localImages: z.array(z.string()), + remoteImages: z.array(z.string()), + hasImages: z.boolean(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry = store.get('imageTest', 'image-test'); + assert.ok(entry); + assert.ok(entry.data.hasImages); + assert.equal(entry.data.localImages.length, 1); + assert.equal(entry.data.localImages[0], './image.png'); + assert.equal(entry.data.remoteImages.length, 0); // Remote images are not tracked in localImagePaths + }); +}); diff --git a/packages/astro/test/units/content-layer/schema-validation.test.js b/packages/astro/test/units/content-layer/schema-validation.test.js new file mode 100644 index 000000000000..344b142bc4ea --- /dev/null +++ b/packages/astro/test/units/content-layer/schema-validation.test.js @@ -0,0 +1,615 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { z } from 'zod'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { Logger } from '../../../dist/core/logger/core.js'; +import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.js'; + +describe('Content Layer - Schema Validation', () => { + const root = createTempDir(); + + it('parses and coerces Date objects in schemas', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + // Loader that provides dates in various formats + const dateLoader = { + name: 'date-loader', + load: async (context) => { + const entries = [ + { + id: 'one', + publishedAt: '2021-01-01', // ISO date string + updatedAt: new Date('2021-01-02'), // Date object + createdAt: '2021-01-03T00:00:00.000Z', // Full ISO string + }, + { + id: 'two', + publishedAt: '2021-01-02', + updatedAt: new Date('2021-01-03'), + createdAt: 1609545600000, // Timestamp + }, + { + id: 'three', + publishedAt: '2021-01-03', + updatedAt: new Date('2021-01-04'), + createdAt: 'January 5, 2021', // Date string + }, + { + id: 'four%', // Special characters in ID + publishedAt: '2021-01-01', + updatedAt: new Date('2021-01-02'), + createdAt: '2021-01-03', + }, + ]; + + for (const entry of entries) { + const parsed = await context.parseData({ + id: entry.id, + data: entry, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } + }, + }; + + const collections = { + withDates: defineCollection({ + loader: dateLoader, + schema: z.object({ + publishedAt: z.coerce.date(), + updatedAt: z.date(), + createdAt: z.coerce.date(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify all entries were stored + const entries = store.values('withDates'); + assert.equal(entries.length, 4); + + // Check IDs including special characters + const ids = entries.map((item) => item.id).sort(); + assert.deepEqual(ids, ['four%', 'one', 'three', 'two']); + + // Verify all dates are Date objects + for (const entry of entries) { + assert.ok(entry.data.publishedAt instanceof Date); + assert.ok(entry.data.updatedAt instanceof Date); + assert.ok(entry.data.createdAt instanceof Date); + } + + // Verify specific date values + const entryOne = store.get('withDates', 'one'); + assert.equal(entryOne.data.publishedAt.toISOString(), '2021-01-01T00:00:00.000Z'); + assert.equal(entryOne.data.updatedAt.toISOString(), '2021-01-02T00:00:00.000Z'); + assert.equal(entryOne.data.createdAt.toISOString(), '2021-01-03T00:00:00.000Z'); + + // Check timestamp conversion + const entryTwo = store.get('withDates', 'two'); + assert.equal(entryTwo.data.createdAt.toISOString(), '2021-01-02T00:00:00.000Z'); + + // Check date string parsing - just verify it's a valid Date + const entryThree = store.get('withDates', 'three'); + assert.ok(entryThree.data.createdAt instanceof Date); + assert.ok(!isNaN(entryThree.data.createdAt.getTime())); + }); + + it('handles custom IDs and slugs', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + // Loader that provides entries with custom slugs + const customSlugLoader = { + name: 'custom-slug-loader', + load: async (context) => { + const entries = [ + { + id: 'fancy-one', + slug: 'fancy-one', + title: 'First Entry', + }, + { + id: 'excellent-three', + slug: 'excellent-three', + title: 'Third Entry', + }, + { + id: 'interesting-two', + slug: 'interesting-two', + title: 'Second Entry', + }, + ]; + + for (const entry of entries) { + const parsed = await context.parseData({ + id: entry.id, + data: entry, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } + }, + }; + + const collections = { + withCustomSlugs: defineCollection({ + loader: customSlugLoader, + schema: z.object({ + slug: z.string(), + title: z.string(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify custom IDs are preserved + const entries = store.values('withCustomSlugs'); + const ids = entries.map((item) => item.id).sort(); + assert.deepEqual(ids, ['excellent-three', 'fancy-one', 'interesting-two']); + + // Verify data is correct + const fancyOne = store.get('withCustomSlugs', 'fancy-one'); + assert.equal(fancyOne.data.slug, 'fancy-one'); + assert.equal(fancyOne.data.title, 'First Entry'); + }); + + it('supports union schemas (discriminated unions)', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + // Loader that provides different types of content + const unionLoader = { + name: 'union-loader', + load: async (context) => { + const entries = [ + { + id: 'post', + type: 'post', + title: 'My Post', + description: 'This is my post', + }, + { + id: 'newsletter', + type: 'newsletter', + subject: 'My Newsletter', + // Note: newsletters don't have title or description + }, + { + id: 'announcement', + type: 'announcement', + message: 'Important Update', + priority: 'high', + }, + ]; + + for (const entry of entries) { + const parsed = await context.parseData({ + id: entry.id, + data: entry, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } + }, + }; + + // Union schema that accepts different shapes based on 'type' field + const collections = { + withUnionSchema: defineCollection({ + loader: unionLoader, + schema: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('post'), + title: z.string(), + description: z.string(), + }), + z.object({ + type: z.literal('newsletter'), + subject: z.string(), + }), + z.object({ + type: z.literal('announcement'), + message: z.string(), + priority: z.enum(['low', 'medium', 'high']), + }), + ]), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify all entries were stored + const entries = store.values('withUnionSchema'); + assert.equal(entries.length, 3); + + // Verify post entry + const post = store.get('withUnionSchema', 'post'); + assert.deepEqual(post.data, { + type: 'post', + title: 'My Post', + description: 'This is my post', + }); + + // Verify newsletter entry + const newsletter = store.get('withUnionSchema', 'newsletter'); + assert.deepEqual(newsletter.data, { + type: 'newsletter', + subject: 'My Newsletter', + }); + + // Verify announcement entry + const announcement = store.get('withUnionSchema', 'announcement'); + assert.deepEqual(announcement.data, { + type: 'announcement', + message: 'Important Update', + priority: 'high', + }); + }); + + it('validates required fields in empty content', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logs = []; + + const logger = new Logger({ + level: 'error', + dest: { + write: (event) => { + logs.push(event); + return true; + }, + }, + }); + + // Loader that simulates empty markdown file scenario + const emptyContentLoader = { + name: 'empty-content-loader', + load: async (context) => { + // Simulate empty markdown file - no frontmatter data + const entries = [ + { + id: 'empty-file', + data: {}, // Empty frontmatter + body: '', // Empty body + }, + { + id: 'partial-file', + data: { + description: 'Has description but missing title', + }, + body: 'Some content', + }, + ]; + + for (const entry of entries) { + try { + const parsed = await context.parseData({ + id: entry.id, + data: entry.data, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + body: entry.body, + }); + } catch (error) { + // Log validation error + context.logger.error(`Validation failed for ${entry.id}: ${error.message}`); + + // Check if it's a Zod error with issues + if (error.errors) { + const requiredFields = error.errors + .filter((issue) => issue.message === 'Required') + .map((issue) => `**${issue.path.join('.')}**: ${issue.message}`); + + if (requiredFields.length > 0) { + context.logger.error(requiredFields.join(', ')); + } + } + } + } + }, + }; + + const collections = { + requiredFields: defineCollection({ + loader: emptyContentLoader, + schema: z.object({ + title: z.string().min(1), + description: z.string().optional(), + publishedAt: z.date().optional(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check that validation errors were logged + const validationErrors = logs.filter((log) => log.level === 'error'); + assert.ok(validationErrors.length > 0); + + // Check for the specific "**title**: Required" error format + const titleRequiredError = logs.find( + (log) => log.level === 'error' && log.message.includes('**title**: Required'), + ); + assert.ok(titleRequiredError, 'Should have logged "**title**: Required" error'); + + // Verify no entries were stored (both failed validation) + const entries = store.values('requiredFields'); + assert.equal(entries.length, 0); + }); + + it('validates ID types and rejects invalid IDs', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logs = []; + + const logger = new Logger({ + level: 'error', + dest: { + write: (event) => { + logs.push(event); + return true; + }, + }, + }); + + // Loader that provides entries with various ID types + const invalidIdLoader = { + name: 'invalid-id-loader', + load: async (context) => { + const entries = [ + { + id: 'valid-string-id', + data: { title: 'Valid Entry' }, + }, + { + id: 123, // Number ID - should be invalid + data: { title: 'Entry with number ID' }, + }, + { + id: null, // Null ID + data: { title: 'Entry with null ID' }, + }, + { + id: '', // Empty string ID + data: { title: 'Entry with empty ID' }, + }, + ]; + + for (const entry of entries) { + try { + // Validate ID type + if (typeof entry.id !== 'string' || !entry.id) { + throw new Error( + `Collection loader returned an entry with an invalid \`id\`: ${JSON.stringify(entry.id)}. IDs must be strings.`, + ); + } + + const parsed = await context.parseData({ + id: entry.id, + data: entry.data, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } catch (error) { + context.logger.error(error.message); + } + } + }, + }; + + const collections = { + withIdValidation: defineCollection({ + loader: invalidIdLoader, + schema: z.object({ + title: z.string(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for ID validation errors + const idErrors = logs.filter( + (log) => + log.level === 'error' && log.message.includes('returned an entry with an invalid `id`'), + ); + assert.ok(idErrors.length >= 2, 'Should have errors for invalid IDs'); + + // Only valid entry should be stored + const entries = store.values('withIdValidation'); + assert.equal(entries.length, 1); + assert.equal(entries[0].id, 'valid-string-id'); + }); + + it('handles empty collections gracefully', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + // Loader that returns no entries + const emptyLoader = { + name: 'empty-loader', + load: async (_context) => { + // Simulate an empty directory - no entries to load + // Just return without adding anything to the store + }, + }; + + const collections = { + emptyCollection: defineCollection({ + loader: emptyLoader, + schema: z.object({ + title: z.string(), + content: z.string(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify collection exists but is empty + const entries = store.values('emptyCollection'); + assert.equal(entries.length, 0); + assert.deepEqual(entries, []); + + // Store should still be functional + assert.ok(store.scopedStore('emptyCollection')); + }); + + it('handles optional fields with defaults', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new Logger({ + dest: { write: () => true }, + level: 'silent', + }); + + const defaultsLoader = { + name: 'defaults-loader', + load: async (context) => { + const entries = [ + { + id: 'full-entry', + data: { + title: 'Full Entry', + draft: false, + tags: ['tag1', 'tag2'], + rating: 5, + }, + }, + { + id: 'minimal-entry', + data: { + title: 'Minimal Entry', + // All optional fields omitted + }, + }, + ]; + + for (const entry of entries) { + const parsed = await context.parseData({ + id: entry.id, + data: entry.data, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } + }, + }; + + const collections = { + withDefaults: defineCollection({ + loader: defaultsLoader, + schema: z.object({ + title: z.string(), + draft: z.boolean().optional().default(true), + tags: z.array(z.string()).optional().default([]), + rating: z.number().optional().default(0), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check full entry + const fullEntry = store.get('withDefaults', 'full-entry'); + assert.equal(fullEntry.data.draft, false); + assert.deepEqual(fullEntry.data.tags, ['tag1', 'tag2']); + assert.equal(fullEntry.data.rating, 5); + + // Check minimal entry has defaults applied + const minimalEntry = store.get('withDefaults', 'minimal-entry'); + assert.equal(minimalEntry.data.draft, true); // Default value + assert.deepEqual(minimalEntry.data.tags, []); // Default value + assert.equal(minimalEntry.data.rating, 0); // Default value + }); +}); diff --git a/packages/astro/test/units/content-layer/store-persistence.test.js b/packages/astro/test/units/content-layer/store-persistence.test.js new file mode 100644 index 000000000000..303a19f16bb3 --- /dev/null +++ b/packages/astro/test/units/content-layer/store-persistence.test.js @@ -0,0 +1,213 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { createTempDir } from './test-helpers.js'; + +describe('Content Layer - Store Persistence', () => { + it('updates the store on new builds', async () => { + const tempDir = createTempDir(); + const dataStoreFile = new URL('./data-store.json', tempDir); + + // First build - create initial data + const store1 = new MutableDataStore(); + store1.set('dogs', 'beagle', { + id: 'beagle', + data: { breed: 'Beagle', temperament: ['Friendly'] }, + }); + + // Save to disk + await fs.writeFile(dataStoreFile, store1.toString()); + + // Second build - load from disk and update + const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + + // Verify existing data persists + const beagle = store2.get('dogs', 'beagle'); + assert.ok(beagle); + assert.equal(beagle.data.breed, 'Beagle'); + + // Add new data + store2.set('dogs', 'poodle', { + id: 'poodle', + data: { breed: 'Poodle', temperament: ['Intelligent'] }, + }); + + // Save again + await fs.writeFile(dataStoreFile, store2.toString()); + + // Third build - verify both entries exist + const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + assert.equal(store3.values('dogs').length, 2); + assert.ok(store3.get('dogs', 'beagle')); + assert.ok(store3.get('dogs', 'poodle')); + }); + + it('clears the store on new build with force flag', async () => { + const tempDir = createTempDir(); + const dataStoreFile = new URL('./data-store.json', tempDir); + + // First build - create data + const store1 = new MutableDataStore(); + store1.set('dogs', 'beagle', { + id: 'beagle', + data: { breed: 'Beagle' }, + }); + store1.metaStore().set('content-config-digest', 'digest1'); + + await fs.writeFile(dataStoreFile, store1.toString()); + + // Second build with force flag - should clear + const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + + // Simulate force flag by clearing all + store2.clearAll(); + + // Add different data + store2.set('cats', 'siamese', { + id: 'siamese', + data: { breed: 'Siamese' }, + }); + + await fs.writeFile(dataStoreFile, store2.toString()); + + // Verify old data is gone, new data exists + const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + assert.equal(store3.values('dogs').length, 0); + assert.equal(store3.values('cats').length, 1); + assert.ok(store3.get('cats', 'siamese')); + }); + + it('clears the store on new build if the content config has changed', async () => { + const tempDir = createTempDir(); + const dataStoreFile = new URL('./data-store.json', tempDir); + + // First build + const store1 = new MutableDataStore(); + store1.set('dogs', 'beagle', { + id: 'beagle', + data: { breed: 'Beagle' }, + }); + store1.metaStore().set('content-config-digest', 'digest1'); + + await fs.writeFile(dataStoreFile, store1.toString()); + + // Second build with different config digest + const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + const previousDigest = store2.metaStore().get('content-config-digest'); + const newDigest = 'digest2'; + + if (previousDigest && previousDigest !== newDigest) { + // Content config changed, clear store + store2.clearAll(); + } + + store2.metaStore().set('content-config-digest', newDigest); + + // Add new data + store2.set('cats', 'tabby', { + id: 'tabby', + data: { breed: 'Tabby' }, + }); + + await fs.writeFile(dataStoreFile, store2.toString()); + + // Verify + const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + assert.equal(store3.values('dogs').length, 0); // Old data cleared + assert.equal(store3.values('cats').length, 1); // New data exists + assert.equal(store3.metaStore().get('content-config-digest'), 'digest2'); + }); + + it('clears the store on new build if the Astro config has changed', async () => { + const tempDir = createTempDir(); + const dataStoreFile = new URL('./data-store.json', tempDir); + + // First build + const store1 = new MutableDataStore(); + store1.set('dogs', 'beagle', { + id: 'beagle', + data: { breed: 'Beagle' }, + }); + store1.metaStore().set('astro-config-digest', 'astroDigest1'); + + await fs.writeFile(dataStoreFile, store1.toString()); + + // Second build with different astro config + const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + const previousAstroDigest = store2.metaStore().get('astro-config-digest'); + const newAstroDigest = 'astroDigest2'; + + if (previousAstroDigest && previousAstroDigest !== newAstroDigest) { + // Astro config changed, clear store + store2.clearAll(); + } + + store2.metaStore().set('astro-config-digest', newAstroDigest); + + // Add new data + store2.set('birds', 'robin', { + id: 'robin', + data: { name: 'Robin' }, + }); + + await fs.writeFile(dataStoreFile, store2.toString()); + + // Verify + const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + assert.equal(store3.values('dogs').length, 0); // Old data cleared + assert.equal(store3.values('birds').length, 1); // New data exists + assert.equal(store3.metaStore().get('astro-config-digest'), 'astroDigest2'); + }); + + it('can handle references being renamed after a build', async () => { + const tempDir = createTempDir(); + const dataStoreFile = new URL('./data-store.json', tempDir); + + // First build - entry with reference + const store1 = new MutableDataStore(); + store1.set('cats', 'siamese', { + id: 'siamese', + data: { breed: 'Siamese' }, + }); + store1.set('posts', 'post1', { + id: 'post1', + data: { + title: 'My Cat', + cat: { collection: 'cats', id: 'siamese' }, + }, + }); + + await fs.writeFile(dataStoreFile, store1.toString()); + + // Second build - rename the cat entry + const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + + // Remove old entry + store2.delete('cats', 'siamese'); + + // Add renamed entry + store2.set('cats', 'siamese-cat', { + id: 'siamese-cat', + data: { breed: 'Siamese' }, + }); + + // Update the reference + const post = store2.get('posts', 'post1'); + if (post) { + post.data.cat = { collection: 'cats', id: 'siamese-cat' }; + store2.set('posts', 'post1', post); + } + + await fs.writeFile(dataStoreFile, store2.toString()); + + // Verify + const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + assert.ok(!store3.get('cats', 'siamese')); // Old entry gone + assert.ok(store3.get('cats', 'siamese-cat')); // New entry exists + + const updatedPost = store3.get('posts', 'post1'); + assert.equal(updatedPost.data.cat.id, 'siamese-cat'); // Reference updated + }); +}); diff --git a/packages/astro/test/units/content-layer/test-helpers.js b/packages/astro/test/units/content-layer/test-helpers.js new file mode 100644 index 000000000000..4f85716f2dee --- /dev/null +++ b/packages/astro/test/units/content-layer/test-helpers.js @@ -0,0 +1,137 @@ +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { mkdtempSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +/** + * Creates a temporary directory for tests + * @param {string} prefix - Optional prefix for the temp directory name + * @returns {URL} The file URL of the created temp directory + */ +export function createTempDir(prefix = 'astro-test-') { + const tempDir = mkdtempSync(path.join(tmpdir(), prefix)); + return pathToFileURL(tempDir + path.sep); +} + +/** + * Creates a test content config observer for unit tests + * @param {Object} collections - The collections configuration + * @returns {Object} A mock content config observer + */ +export function createTestConfigObserver(collections) { + const contentConfig = { + status: 'loaded', + config: { + collections, + digest: 'test-digest', + }, + }; + + return { + get: () => contentConfig, + set: () => {}, + subscribe: (fn) => { + // Call immediately with current config + fn(contentConfig); + return () => {}; + }, + }; +} + +/** + * Creates minimal Astro settings for content layer tests + * @param {URL} root - The root URL for the test + * @param {Object} overrides - Optional overrides for specific settings + * @returns {Object} Astro settings object + */ +export function createMinimalSettings(root, overrides = {}) { + const defaultConfig = { + root, + srcDir: new URL('./src/', root), + cacheDir: new URL('./.cache/', root), + markdown: {}, + experimental: {}, + }; + + const settings = { + config: { + ...defaultConfig, + ...(overrides.config || {}), + }, + dotAstroDir: new URL('./.astro/', root), + contentEntryTypes: [], + dataEntryTypes: [], + }; + + // Apply non-config overrides + Object.keys(overrides).forEach((key) => { + if (key !== 'config') { + settings[key] = overrides[key]; + } + }); + + return settings; +} + +/** + * Simple YAML frontmatter parser for markdown files + * @param {string} contents - The file contents + * @param {string} fileUrl - The file URL + * @returns {Object} Parsed frontmatter data, body, and slug + */ +export function parseSimpleMarkdownFrontmatter(contents, fileUrl) { + const lines = contents.split('\n'); + const frontmatterStart = lines.findIndex((l) => l === '---'); + const frontmatterEnd = lines.findIndex((l, i) => i > frontmatterStart && l === '---'); + + if (frontmatterStart === -1 || frontmatterEnd === -1) { + const slug = path.basename(fileUrl.pathname || fileUrl, '.md'); + return { data: {}, body: contents, slug, rawData: {} }; + } + + const frontmatterLines = lines.slice(frontmatterStart + 1, frontmatterEnd); + const body = lines.slice(frontmatterEnd + 1).join('\n'); + + // Parse YAML-like frontmatter + const data = {}; + for (const line of frontmatterLines) { + const [key, ...valueParts] = line.split(':'); + if (key && valueParts.length) { + const value = valueParts.join(':').trim(); + if (value.startsWith('[') && value.endsWith(']')) { + // Parse YAML-style arrays + const arrayContent = value.slice(1, -1); + data[key.trim()] = arrayContent + .split(',') + .map((item) => item.trim().replace(/^["']|["']$/g, '')) + .filter((item) => item.length > 0); + } else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + // Keep dates as strings for schema to parse + data[key.trim()] = value; + } else { + // Remove quotes if present + data[key.trim()] = value.replace(/^["']|["']$/g, ''); + } + } + } + + const slug = path.basename(fileUrl.pathname || fileUrl, '.md'); + return { data, body, slug, rawData: data }; +} + +/** + * Creates a markdown entry type configuration + * @param {Function} getEntryInfo - Optional custom getEntryInfo function + * @returns {Object} Entry type configuration for markdown files + */ +export function createMarkdownEntryType(getEntryInfo = parseSimpleMarkdownFrontmatter) { + return { + extensions: ['.md'], + getEntryInfo: async ({ contents, fileUrl }) => { + if (typeof fileUrl === 'string') { + return getEntryInfo(contents, fileUrl); + } + return getEntryInfo(contents, fileUrl); + }, + }; +} diff --git a/packages/astro/test/units/i18n/manual-middleware.test.js b/packages/astro/test/units/i18n/manual-middleware.test.js new file mode 100644 index 000000000000..10e7ce163bd2 --- /dev/null +++ b/packages/astro/test/units/i18n/manual-middleware.test.js @@ -0,0 +1,546 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { requestHasLocale, redirectToDefaultLocale, notFound } from '../../../dist/i18n/index.js'; +import { createManualRoutingContext, createMiddlewarePayload } from './test-helpers.js'; +import { createMockNext } from '../test-utils.js'; + +describe('Custom Middleware with Allowlist Pattern', () => { + describe('allowlist bypasses i18n routing', () => { + it('should allow /help to bypass locale check', async () => { + const allowList = new Set(['/help', '/help/']); + const context = createManualRoutingContext({ pathname: '/help' }); + const next = createMockNext(new Response('Help page')); + + // Middleware logic: if allowlist matches, call next() + let response; + if (allowList.has(context.url.pathname)) { + response = await next(); + } + + assert.ok(next.called); + assert.equal(await response.text(), 'Help page'); + }); + + it('should allow /about if in allowlist', async () => { + const allowList = new Set(['/about']); + const context = createManualRoutingContext({ pathname: '/about' }); + const next = createMockNext(new Response('About page')); + + let response; + if (allowList.has(context.url.pathname)) { + response = await next(); + } + + assert.ok(next.called); + assert.equal(await response.text(), 'About page'); + }); + + it('should not call next() for non-allowlisted paths', async () => { + const allowList = new Set(['/help']); + const context = createManualRoutingContext({ pathname: '/blog' }); + const next = createMockNext(); + + let response = null; + if (!allowList.has(context.url.pathname)) { + // Path not in allowlist, don't call next + response = new Response(null, { status: 404 }); + } + + assert.equal(next.called, false); + assert.ok(response); + assert.equal(response.status, 404); + }); + }); + + describe('paths with locales proceed to next()', () => { + it('should call next() when requestHasLocale returns true', async () => { + const locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/en/blog' }); + const next = createMockNext(new Response('Blog page')); + + let response; + if (hasLocale(context)) { + response = await next(); + } + + assert.ok(next.called); + assert.equal(await response.text(), 'Blog page'); + }); + + it('should call next() for /spanish with locale object', async () => { + const locales = ['en', { path: 'spanish', codes: ['es'] }]; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/spanish' }); + const next = createMockNext(new Response('Spanish page')); + + let response = null; + if (hasLocale(context)) { + response = await next(); + } + + assert.ok(next.called); + assert.ok(response); + }); + + it('should not call next() for paths without locale', async () => { + const locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/blog' }); + const next = createMockNext(); + + let response = null; + if (hasLocale(context)) { + response = await next(); + } else { + response = new Response(null, { status: 404 }); + } + + assert.equal(next.called, false); + assert.ok(response); + assert.equal(response.status, 404); + }); + }); + + describe('root path redirects to default locale', () => { + it('should redirect / to default locale without calling next()', async () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + const next = createMockNext(); + + let response; + if (context.url.pathname === '/') { + response = redirect(context); + } else { + response = await next(); + } + + assert.equal(next.called, false); // next() should NOT be called + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/en/'); + }); + + it('should redirect with custom status code', async () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context, 301); + + assert.equal(response.status, 301); + }); + }); + + describe('unknown paths return 404', () => { + it('should return 404 for unknown paths without calling next()', async () => { + const locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/unknown' }); + const next = createMockNext(); + + let response = null; + if (hasLocale(context)) { + response = await next(); + } else if (context.url.pathname !== '/') { + response = new Response(null, { status: 404 }); + } + + assert.equal(next.called, false); + assert.ok(response); + assert.equal(response.status, 404); + }); + + it('should return 404 for /blog without locale', async () => { + const locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/blog' }); + + let response = null; + if (!hasLocale(context) && context.url.pathname !== '/') { + response = new Response(null, { status: 404 }); + } + + assert.ok(response); + assert.equal(response.status, 404); + }); + }); + + describe('special 404 route handling', () => { + it('should redirect /redirect-me to default locale', async () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/redirect-me' }); + + // Middleware logic from fixture + let response = null; + if (context.url.pathname === '/' || context.url.pathname === '/redirect-me') { + response = redirect(context); + } + + assert.ok(response); + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/en/'); + }); + }); +}); + +describe('Middleware Flow Control', () => { + describe('decision tree execution order', () => { + it('should check allowlist first, then locale, then root, then 404', async () => { + const allowList = new Set(['/help']); + const locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + + // Test function that mimics the middleware from fixture + async function middleware(pathname) { + const context = createManualRoutingContext({ pathname }); + const next = createMockNext(new Response('Page content')); + + // Step 1: Check allowlist + if (allowList.has(context.url.pathname)) { + return { response: await next(), calledNext: true }; + } + + // Step 2: Check if has locale + if (hasLocale(context)) { + return { response: await next(), calledNext: true }; + } + + // Step 3: Check if root or special path + if (context.url.pathname === '/' || context.url.pathname === '/redirect-me') { + return { response: redirect(context), calledNext: false }; + } + + // Step 4: Return 404 + return { response: new Response(null, { status: 404 }), calledNext: false }; + } + + // Test allowlist path + const result1 = await middleware('/help'); + assert.equal(result1.calledNext, true); + assert.equal(await result1.response.text(), 'Page content'); + + // Test locale path + const result2 = await middleware('/en/blog'); + assert.equal(result2.calledNext, true); + + // Test root path + const result3 = await middleware('/'); + assert.equal(result3.calledNext, false); + assert.equal(result3.response.status, 302); + + // Test unknown path + const result4 = await middleware('/unknown'); + assert.equal(result4.calledNext, false); + assert.equal(result4.response.status, 404); + }); + + it('should short-circuit on allowlist match', async () => { + const allowList = new Set(['/help']); + const locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/help' }); + const next = createMockNext(new Response('Help page')); + + // Middleware should return immediately after allowlist check + let response = null; + if (allowList.has(context.url.pathname)) { + response = await next(); + } else if (hasLocale(context)) { + // This should not execute + assert.fail('Should not check locale after allowlist match'); + } + + assert.ok(next.called); + assert.ok(response); + }); + + it('should short-circuit on locale match', async () => { + const locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/en/blog' }); + const next = createMockNext(new Response('Blog')); + + let response = null; + if (hasLocale(context)) { + response = await next(); + } else if (context.url.pathname === '/') { + // This should not execute + assert.fail('Should not check root after locale match'); + } + + assert.ok(next.called); + assert.ok(response); + }); + }); + + describe('early return patterns', () => { + it('should return immediately when allowlist matches', async () => { + const allowList = new Set(['/help']); + const context = createManualRoutingContext({ pathname: '/help' }); + const next = createMockNext(new Response('Help')); + + let executedNext = false; + let executedOther = false; + + let response = null; + if (allowList.has(context.url.pathname)) { + executedNext = true; + response = await next(); + // Early return, nothing after should execute + } else { + executedOther = true; + } + + assert.equal(executedNext, true); + assert.equal(executedOther, false); + assert.ok(response); + }); + + it('should not call next() when redirecting', async () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + const next = createMockNext(); + + let response; + if (context.url.pathname === '/') { + response = redirect(context); + // Should return here, not call next() + } else { + response = await next(); + } + + assert.equal(next.called, false); + assert.equal(response.status, 302); + }); + + it('should not call next() when returning 404', async () => { + const locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/unknown' }); + const next = createMockNext(); + + let response = null; + if (hasLocale(context)) { + response = await next(); + } else { + response = new Response(null, { status: 404 }); + // Should return here, not call next() + } + + assert.equal(next.called, false); + assert.ok(response); + assert.equal(response.status, 404); + }); + }); + + describe('response propagation', () => { + it('should propagate response from next() when locale found', async () => { + const locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/en/blog' }); + const expectedResponse = new Response('Blog content', { + status: 200, + headers: { 'X-Custom': 'value' }, + }); + const next = createMockNext(expectedResponse); + + let response; + if (hasLocale(context)) { + response = await next(); + } + + assert.equal(response, expectedResponse); + assert.equal(response.headers.get('X-Custom'), 'value'); + }); + + it('should propagate custom response from allowlist route', async () => { + const allowList = new Set(['/api/health']); + const context = createManualRoutingContext({ pathname: '/api/health' }); + const healthResponse = new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + const next = createMockNext(healthResponse); + + let response; + if (allowList.has(context.url.pathname)) { + response = await next(); + } + + assert.equal(response.headers.get('Content-Type'), 'application/json'); + assert.equal(await response.text(), JSON.stringify({ status: 'ok' })); + }); + }); +}); + +describe('Complete Middleware Scenarios', () => { + describe('fixture middleware pattern', () => { + /** + * This replicates the exact middleware from the i18n-routing-manual fixture + */ + async function fixtureMiddleware(pathname) { + const allowList = new Set(['/help', '/help/']); + const locales = ['en', 'pt', 'it', { path: 'spanish', codes: ['es', 'es-ar'] }]; + const payload = createMiddlewarePayload({ + defaultLocale: 'en', + locales, + }); + + const hasLocale = requestHasLocale(locales); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname }); + const next = createMockNext(new Response('Page content')); + + // Replicate exact middleware logic + if (allowList.has(context.url.pathname)) { + return await next(); + } + if (hasLocale(context)) { + return await next(); + } + if (context.url.pathname === '/' || context.url.pathname === '/redirect-me') { + return redirect(context); + } + return new Response(null, { status: 404 }); + } + + it('should handle all fixture test cases correctly', async () => { + // Test case 1: Root redirects to /en/ + const response1 = await fixtureMiddleware('/'); + assert.equal(response1.status, 302); + assert.equal(response1.headers.get('Location'), '/en/'); + + // Test case 2: /help is allowed (not i18n) + const response2 = await fixtureMiddleware('/help'); + assert.equal(await response2.text(), 'Page content'); + + // Test case 3: /en/blog has locale + const response3 = await fixtureMiddleware('/en/blog'); + assert.equal(await response3.text(), 'Page content'); + + // Test case 4: /pt/start has locale + const response4 = await fixtureMiddleware('/pt/start'); + assert.equal(await response4.text(), 'Page content'); + + // Test case 5: /spanish has locale (object path) + const response5 = await fixtureMiddleware('/spanish'); + assert.equal(await response5.text(), 'Page content'); + + // Test case 6: /redirect-me redirects like root + const response6 = await fixtureMiddleware('/redirect-me'); + assert.equal(response6.status, 302); + assert.equal(response6.headers.get('Location'), '/en/'); + + // Test case 7: Unknown path returns 404 + const response7 = await fixtureMiddleware('/unknown'); + assert.equal(response7.status, 404); + + // Test case 8: /blog without locale returns 404 + const response8 = await fixtureMiddleware('/blog'); + assert.equal(response8.status, 404); + }); + + it('should not match locale codes for locale objects', async () => { + // /es should NOT match the spanish locale object (only /spanish matches) + const response = await fixtureMiddleware('/es'); + assert.equal(response.status, 404); + }); + + it('should handle trailing slash in allowlist', async () => { + const response = await fixtureMiddleware('/help/'); + assert.equal(await response.text(), 'Page content'); + }); + }); + + describe('middleware with base path', () => { + async function middlewareWithBase(pathname, base = '/blog') { + const locales = ['en', 'es']; + const payload = createMiddlewarePayload({ + base, + defaultLocale: 'en', + locales, + }); + + const hasLocale = requestHasLocale(locales); + const redirect = redirectToDefaultLocale(payload); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname }); + const next = createMockNext(new Response('Page')); + + if (hasLocale(context)) { + return await next(); + } + if (context.url.pathname === base || context.url.pathname === base + '/') { + return redirect(context); + } + const result = notFoundFn(context); + return result || new Response(null, { status: 404 }); + } + + it('should redirect base path to base + locale', async () => { + const response = await middlewareWithBase('/blog'); + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/blog/en/'); + }); + + it('should allow paths with locale under base', async () => { + const response = await middlewareWithBase('/blog/en/post'); + assert.equal(await response.text(), 'Page'); + }); + + it('should return 404 for paths without locale under base', async () => { + const response = await middlewareWithBase('/blog/about'); + assert.equal(response.status, 404); + }); + }); + + describe('middleware with custom responses', () => { + it('should allow custom response from middleware before calling next()', async () => { + const allowList = new Set(['/api/status']); + const context = createManualRoutingContext({ pathname: '/api/status' }); + const next = createMockNext(); + + let response; + if (allowList.has(context.url.pathname)) { + // Return custom JSON response without calling next() + response = new Response(JSON.stringify({ status: 'healthy' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } else { + response = await next(); + } + + assert.equal(next.called, false); + assert.equal(response.status, 200); + assert.equal(await response.text(), JSON.stringify({ status: 'healthy' })); + }); + + it('should modify response after next() call', async () => { + const locales = ['en']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/en/api' }); + const next = createMockNext(new Response('Data')); + + let response; + if (hasLocale(context)) { + const originalResponse = await next(); + // Add custom header to response from next() + response = new Response(originalResponse.body, { + status: originalResponse.status, + headers: { + ...Object.fromEntries(originalResponse.headers), + 'X-Custom-Header': 'added-by-middleware', + }, + }); + } + + assert.ok(next.called); + assert.equal(response.headers.get('X-Custom-Header'), 'added-by-middleware'); + }); + }); +}); diff --git a/packages/astro/test/units/i18n/manual-routing.test.js b/packages/astro/test/units/i18n/manual-routing.test.js new file mode 100644 index 000000000000..06b3d597ab09 --- /dev/null +++ b/packages/astro/test/units/i18n/manual-routing.test.js @@ -0,0 +1,1349 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + normalizeTheLocale, + normalizeThePath, + pathHasLocale, + requestHasLocale, + redirectToDefaultLocale, + notFound, + redirectToFallback, +} from '../../../dist/i18n/index.js'; +import { REROUTE_DIRECTIVE_HEADER } from '../../../dist/core/constants.js'; +import { createManualRoutingContext, createMiddlewarePayload } from './test-helpers.js'; + +describe('normalizeTheLocale', () => { + it('should convert underscores to dashes', () => { + assert.equal(normalizeTheLocale('en_US'), 'en-us'); + assert.equal(normalizeTheLocale('pt_BR'), 'pt-br'); + assert.equal(normalizeTheLocale('zh_Hans_CN'), 'zh-hans-cn'); + }); + + it('should convert to lowercase', () => { + assert.equal(normalizeTheLocale('EN'), 'en'); + assert.equal(normalizeTheLocale('ES'), 'es'); + assert.equal(normalizeTheLocale('PT'), 'pt'); + }); + + it('should convert both underscores and case', () => { + assert.equal(normalizeTheLocale('EN_US'), 'en-us'); + assert.equal(normalizeTheLocale('Es_AR'), 'es-ar'); + }); + + it('should handle already normalized locales', () => { + assert.equal(normalizeTheLocale('en-us'), 'en-us'); + assert.equal(normalizeTheLocale('en'), 'en'); + assert.equal(normalizeTheLocale('pt-br'), 'pt-br'); + }); + + it('should handle edge cases', () => { + assert.equal(normalizeTheLocale(''), ''); + assert.equal(normalizeTheLocale('a'), 'a'); + }); +}); + +describe('normalizeThePath', () => { + it('should remove .html extension', () => { + assert.equal(normalizeThePath('/en/blog.html'), '/en/blog'); + assert.equal(normalizeThePath('/spanish.html'), '/spanish'); + assert.equal(normalizeThePath('en.html'), 'en'); + }); + + it('should not modify paths without .html', () => { + assert.equal(normalizeThePath('/en/blog'), '/en/blog'); + assert.equal(normalizeThePath('/spanish'), '/spanish'); + assert.equal(normalizeThePath('/'), '/'); + }); + + it('should not remove other extensions', () => { + assert.equal(normalizeThePath('/en/blog.php'), '/en/blog.php'); + assert.equal(normalizeThePath('/api.json'), '/api.json'); + assert.equal(normalizeThePath('/file.txt'), '/file.txt'); + }); + + it('should handle edge cases', () => { + assert.equal(normalizeThePath(''), ''); + assert.equal(normalizeThePath('.html'), ''); + assert.equal(normalizeThePath('a.html'), 'a'); + }); +}); + +describe('pathHasLocale', () => { + describe('string locales - basic matching', () => { + it('should return true when path contains string locale', () => { + assert.equal(pathHasLocale('/en', ['en', 'es']), true); + assert.equal(pathHasLocale('/es', ['en', 'es']), true); + assert.equal(pathHasLocale('/pt', ['en', 'es', 'pt']), true); + }); + + it('should return true when path contains locale in nested path', () => { + assert.equal(pathHasLocale('/en/about', ['en', 'es']), true); + assert.equal(pathHasLocale('/es/blog/post', ['en', 'es']), true); + assert.equal(pathHasLocale('/pt/nested/deep/path', ['pt']), true); + }); + + it('should return false when path does not contain locale', () => { + assert.equal(pathHasLocale('/fr', ['en', 'es']), false); + assert.equal(pathHasLocale('/about', ['en', 'es']), false); + assert.equal(pathHasLocale('/blog/post', ['en', 'es']), false); + }); + + it('should return false for root path', () => { + assert.equal(pathHasLocale('/', ['en', 'es']), false); + }); + }); + + describe('string locales - case insensitive matching', () => { + it('should match locale regardless of case in path', () => { + assert.equal(pathHasLocale('/EN', ['en']), true); + assert.equal(pathHasLocale('/En', ['en']), true); + assert.equal(pathHasLocale('/eN', ['en']), true); + }); + + it('should match locale regardless of case in config', () => { + assert.equal(pathHasLocale('/en', ['EN']), true); + assert.equal(pathHasLocale('/en', ['En']), true); + }); + + it('should handle underscore to dash normalization in path', () => { + assert.equal(pathHasLocale('/en_US', ['en-us']), true); + assert.equal(pathHasLocale('/pt_BR', ['pt-br']), true); + }); + + it('should handle dash to underscore normalization in config', () => { + assert.equal(pathHasLocale('/en-us', ['en_US']), true); + assert.equal(pathHasLocale('/pt-br', ['pt_BR']), true); + }); + + it('should handle mixed case and separators', () => { + assert.equal(pathHasLocale('/EN_us', ['en-US']), true); + assert.equal(pathHasLocale('/pt-BR', ['PT_br']), true); + }); + }); + + describe('object locales - path matching', () => { + it('should match locale object by path', () => { + const locales = [{ path: 'spanish', codes: ['es', 'es-ar'] }]; + assert.equal(pathHasLocale('/spanish', locales), true); + }); + + it('should match locale object in nested path', () => { + const locales = [{ path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/spanish/blog', locales), true); + assert.equal(pathHasLocale('/spanish/blog/post', locales), true); + }); + + it('should not match locale codes, only path', () => { + const locales = [{ path: 'spanish', codes: ['es', 'es-ar'] }]; + assert.equal(pathHasLocale('/es', locales), false); + assert.equal(pathHasLocale('/es-ar', locales), false); + }); + + it('should match multiple locale objects', () => { + const locales = [ + { path: 'spanish', codes: ['es'] }, + { path: 'portuguese', codes: ['pt'] }, + ]; + assert.equal(pathHasLocale('/spanish', locales), true); + assert.equal(pathHasLocale('/portuguese', locales), true); + }); + }); + + describe('mixed locales', () => { + it('should match string locale in mixed array', () => { + const locales = ['en', { path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/en/blog', locales), true); + }); + + it('should match object locale in mixed array', () => { + const locales = ['en', { path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/spanish/blog', locales), true); + }); + + it('should not match undefined locale', () => { + const locales = ['en', { path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/pt', locales), false); + assert.equal(pathHasLocale('/fr/blog', locales), false); + }); + + it('should work with complex mixed config', () => { + const locales = [ + 'en', + 'fr', + { path: 'spanish', codes: ['es', 'es-ar'] }, + 'pt', + { path: 'italiano', codes: ['it', 'it-va'] }, + ]; + assert.equal(pathHasLocale('/en', locales), true); + assert.equal(pathHasLocale('/fr/about', locales), true); + assert.equal(pathHasLocale('/spanish', locales), true); + assert.equal(pathHasLocale('/pt/blog', locales), true); + assert.equal(pathHasLocale('/italiano', locales), true); + assert.equal(pathHasLocale('/de', locales), false); + }); + }); + + describe('HTML extension handling (SSG)', () => { + it('should match locale with .html extension', () => { + assert.equal(pathHasLocale('/en.html', ['en']), true); + assert.equal(pathHasLocale('/es.html', ['en', 'es']), true); + }); + + it('should match locale object path with .html', () => { + const locales = [{ path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/spanish.html', locales), true); + }); + + it('should match nested paths with .html', () => { + assert.equal(pathHasLocale('/en/blog.html', ['en']), true); + assert.equal(pathHasLocale('/es/about/us.html', ['es']), true); + }); + + it('should strip .html before checking locale', () => { + const locales = [{ path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/spanish.html', locales), true); + // But not match the code + assert.equal(pathHasLocale('/es.html', locales), false); + }); + }); + + describe('edge cases', () => { + it('should handle root path', () => { + assert.equal(pathHasLocale('/', ['en', 'es']), false); + }); + + it('should handle empty path', () => { + assert.equal(pathHasLocale('', ['en', 'es']), false); + }); + + it('should handle trailing slash', () => { + assert.equal(pathHasLocale('/en/', ['en']), true); + assert.equal(pathHasLocale('/es/blog/', ['es']), true); + }); + + it('should handle path with only locale and trailing slash', () => { + const locales = [{ path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/spanish/', locales), true); + }); + + it('should handle multiple consecutive slashes', () => { + assert.equal(pathHasLocale('/en//blog', ['en']), true); + assert.equal(pathHasLocale('//en/blog', ['en']), true); + }); + + it('should not match partial locale segments', () => { + assert.equal(pathHasLocale('/english', ['en']), false); + assert.equal(pathHasLocale('/item', ['it']), false); + assert.equal(pathHasLocale('/open', ['en']), false); + }); + + it('should handle empty locales array', () => { + assert.equal(pathHasLocale('/en', []), false); + assert.equal(pathHasLocale('/', []), false); + }); + + it('should handle single character locales', () => { + assert.equal(pathHasLocale('/a', ['a', 'b']), true); + assert.equal(pathHasLocale('/b/page', ['a', 'b']), true); + }); + }); +}); + +describe('requestHasLocale', () => { + it('should return a function', () => { + const hasLocale = requestHasLocale(['en', 'es']); + assert.equal(typeof hasLocale, 'function'); + }); + + it('should check context.url.pathname for locale', () => { + const hasLocale = requestHasLocale(['en', 'es']); + const context = createManualRoutingContext({ pathname: '/en/blog' }); + assert.equal(hasLocale(context), true); + }); + + it('should return true for paths with configured locales', () => { + const hasLocale = requestHasLocale(['en', 'es', 'pt']); + + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/en' })), true); + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/es/about' })), true); + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/pt/blog/post' })), true); + }); + + it('should return false for paths without locales', () => { + const hasLocale = requestHasLocale(['en', 'es']); + + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/blog' })), false); + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/about' })), false); + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/' })), false); + }); + + it('should work with locale objects', () => { + const hasLocale = requestHasLocale(['en', { path: 'spanish', codes: ['es', 'es-ar'] }]); + + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/en' })), true); + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/spanish' })), true); + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/es' })), false); + }); + + it('should not modify context', () => { + const hasLocale = requestHasLocale(['en']); + const context = createManualRoutingContext({ pathname: '/en/blog' }); + const originalPathname = context.url.pathname; + + hasLocale(context); + + assert.equal(context.url.pathname, originalPathname); + }); + + it('should handle different hostnames', () => { + const hasLocale = requestHasLocale(['en', 'es']); + + const context1 = createManualRoutingContext({ pathname: '/en', hostname: 'localhost' }); + const context2 = createManualRoutingContext({ pathname: '/en', hostname: '127.0.0.1' }); + + assert.equal(hasLocale(context1), true); + assert.equal(hasLocale(context2), true); + }); + + it('should work consistently across multiple calls', () => { + const hasLocale = requestHasLocale(['en', 'es']); + const context = createManualRoutingContext({ pathname: '/en/blog' }); + + assert.equal(hasLocale(context), true); + assert.equal(hasLocale(context), true); + assert.equal(hasLocale(context), true); + }); +}); + +describe('redirectToDefaultLocale', () => { + describe('basic redirect generation', () => { + it('should create a function that returns a Response', () => { + const payload = createMiddlewarePayload({ + defaultLocale: 'en', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.ok(response instanceof Response); + }); + + it('should redirect to default locale with no base', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.equal(response.status, 302); + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/en/'); + }); + + it('should use default status 302', () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.equal(response.status, 302); + }); + }); + + describe('custom status codes', () => { + it('should accept custom status code 301', () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context, 301); + + assert.equal(response.status, 301); + // Default payload has trailingSlash: 'ignore' + format: 'directory' + assert.equal(response.headers.get('Location'), '/en/'); + }); + + it('should accept custom status code 307', () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context, 307); + + assert.equal(response.status, 307); + }); + + it('should accept custom status code 308', () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context, 308); + + assert.equal(response.status, 308); + }); + }); + + describe('base path handling', () => { + it('should redirect to base + locale', () => { + const payload = createMiddlewarePayload({ + base: '/blog', + defaultLocale: 'en', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/blog/en/'); + }); + + it('should handle base with leading slash', () => { + const payload = createMiddlewarePayload({ + base: '/my-site', + defaultLocale: 'pt', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/my-site/pt/'); + }); + + it('should handle base with trailing slash', () => { + const payload = createMiddlewarePayload({ + base: '/blog/', + defaultLocale: 'en', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // joinPaths normalizes, then trailingSlash: 'ignore' + format: 'directory' adds / + assert.equal(response.headers.get('Location'), '/blog/en/'); + }); + + it('should handle complex base paths', () => { + const payload = createMiddlewarePayload({ + base: '/sites/my-app', + defaultLocale: 'es', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/sites/my-app/es/'); + }); + }); + + describe('trailing slash behavior', () => { + it('should add trailing slash with trailingSlash: always and format: directory', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'always', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.equal(response.headers.get('Location'), '/en/'); + }); + + it('should not add trailing slash with trailingSlash: never', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'never', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.equal(response.headers.get('Location'), '/en'); + }); + + it('should add trailing slash with trailingSlash: ignore and format: directory', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/en/'); + }); + + it('should add trailing slash with trailingSlash: always and format: file', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'always', + format: 'file', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.equal(response.headers.get('Location'), '/en/'); + }); + + it('should not add trailing slash with trailingSlash: never and format: file', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'never', + format: 'file', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.equal(response.headers.get('Location'), '/en'); + }); + }); + + describe('combined scenarios', () => { + it('should handle base + trailing slash + status code', () => { + const payload = createMiddlewarePayload({ + base: '/blog', + defaultLocale: 'pt', + trailingSlash: 'always', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context, 301); + + assert.equal(response.status, 301); + assert.equal(response.headers.get('Location'), '/blog/pt/'); + }); + + it('should handle complex locale codes', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'es-AR', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/es-AR/'); + }); + + it('should work with underscore locales', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en_US', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/en_US/'); + }); + + it('should handle all parameters combined', () => { + const payload = createMiddlewarePayload({ + base: '/sites/app', + defaultLocale: 'pt-BR', + trailingSlash: 'always', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context, 307); + + assert.equal(response.status, 307); + assert.equal(response.headers.get('Location'), '/sites/app/pt-BR/'); + }); + }); + + describe('context independence', () => { + it('should work regardless of context pathname', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + + // All should redirect to the same place + const response1 = redirect(createManualRoutingContext({ pathname: '/' })); + const response2 = redirect(createManualRoutingContext({ pathname: '/about' })); + const response3 = redirect(createManualRoutingContext({ pathname: '/blog/post' })); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response1.headers.get('Location'), '/en/'); + assert.equal(response2.headers.get('Location'), '/en/'); + assert.equal(response3.headers.get('Location'), '/en/'); + }); + }); +}); + +describe('notFound', () => { + describe('basic 404 for non-locale paths', () => { + it('should return 404 Response for paths without locale', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + + const response = notFoundFn(context); + + assert.ok(response instanceof Response); + assert.equal(response.status, 404); + }); + + it('should return 404 for /about with configured locales', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/about' }); + + const response = notFoundFn(context); + + assert.equal(response.status, 404); + }); + + it('should set REROUTE_DIRECTIVE_HEADER to no', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + + const response = notFoundFn(context); + + assert.equal(response.headers.get(REROUTE_DIRECTIVE_HEADER), 'no'); + }); + }); + + describe('root path handling', () => { + it('should return undefined for / (root)', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = notFoundFn(context); + + assert.equal(response, undefined); + }); + + it('should return undefined for base path as root', () => { + const payload = createMiddlewarePayload({ + base: '/blog', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + + const response = notFoundFn(context); + + assert.equal(response, undefined); + }); + + it('should return undefined for base path with trailing slash', () => { + const payload = createMiddlewarePayload({ + base: '/blog', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog/' }); + + const response = notFoundFn(context); + + assert.equal(response, undefined); + }); + }); + + describe('locale paths allowed', () => { + it('should return undefined for valid locale paths', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/en/blog' })), undefined); + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/es/about' })), undefined); + }); + + it('should return undefined for locale object paths', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: [{ path: 'spanish', codes: ['es'] }], + }); + const notFoundFn = notFound(payload); + + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/spanish' })), undefined); + assert.equal( + notFoundFn(createManualRoutingContext({ pathname: '/spanish/blog' })), + undefined, + ); + }); + + it('should return undefined for mixed locale config', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', { path: 'spanish', codes: ['es'] }], + }); + const notFoundFn = notFound(payload); + + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/en' })), undefined); + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/spanish' })), undefined); + }); + }); + + describe('response parameter handling', () => { + it('should preserve body when Response is passed', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + const originalResponse = new Response('Original body', { status: 200 }); + + const response = notFoundFn(context, originalResponse); + + assert.equal(response.status, 404); + assert.equal(response.body, originalResponse.body); + }); + + it('should copy headers when Response is passed', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + const originalResponse = new Response('body', { + status: 200, + headers: { 'X-Custom': 'value' }, + }); + + const response = notFoundFn(context, originalResponse); + + assert.equal(response.status, 404); + assert.equal(response.headers.get('X-Custom'), 'value'); + }); + + it('should override status to 404 when Response is passed', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + const originalResponse = new Response('body', { status: 200 }); + + const response = notFoundFn(context, originalResponse); + + assert.equal(response.status, 404); + }); + + it('should set REROUTE_DIRECTIVE_HEADER on passed Response', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + const originalResponse = new Response('body'); + + const response = notFoundFn(context, originalResponse); + + assert.equal(response.headers.get(REROUTE_DIRECTIVE_HEADER), 'no'); + }); + + it('should return original response when REROUTE_DIRECTIVE_HEADER is no and no fallback', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + fallback: undefined, + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + const originalResponse = new Response('body', { + headers: { [REROUTE_DIRECTIVE_HEADER]: 'no' }, + }); + + const response = notFoundFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + }); + + describe('fallback configuration', () => { + it('should still return 404 for non-locale paths with fallback configured', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + fallback: { es: 'en' }, + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + + const response = notFoundFn(context); + + assert.equal(response.status, 404); + }); + + it('should not return original response with fallback when REROUTE_DIRECTIVE_HEADER is no', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + fallback: { es: 'en' }, + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + const originalResponse = new Response('body', { + headers: { [REROUTE_DIRECTIVE_HEADER]: 'no' }, + }); + + const response = notFoundFn(context, originalResponse); + + // With fallback defined, it should not return the original + assert.notEqual(response, originalResponse); + assert.equal(response.status, 404); + }); + }); + + describe('base path handling', () => { + it('should return 404 for non-locale paths with base', () => { + const payload = createMiddlewarePayload({ + base: '/blog', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog/about' }); + + const response = notFoundFn(context); + + assert.equal(response.status, 404); + }); + + it('should allow locale paths with base', () => { + const payload = createMiddlewarePayload({ + base: '/blog', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + + assert.equal( + notFoundFn(createManualRoutingContext({ pathname: '/blog/en/about' })), + undefined, + ); + assert.equal( + notFoundFn(createManualRoutingContext({ pathname: '/blog/es/post' })), + undefined, + ); + }); + + it('should return 404 for paths without locale under base', () => { + const payload = createMiddlewarePayload({ + base: '/site', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + + const response = notFoundFn(createManualRoutingContext({ pathname: '/site/contact' })); + + assert.equal(response.status, 404); + }); + }); + + describe('edge cases', () => { + it('should handle empty pathname', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '' }); + + // Empty pathname is treated as root + const response = notFoundFn(context); + + // Based on implementation, empty string might be treated as root + assert.ok(response === undefined || response.status === 404); + }); + + it('should handle case sensitivity in locale matching', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + + // Normalized matching + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/EN' })), undefined); + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/Es' })), undefined); + }); + + it('should work with single locale', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en'], + }); + const notFoundFn = notFound(payload); + + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/en' })), undefined); + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/es' })).status, 404); + }); + + it('should return null body for 404 without passed Response', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + + const response = notFoundFn(context); + + assert.equal(response.body, null); + }); + }); +}); + +describe('redirectToFallback', () => { + describe('basic fallback behavior', () => { + it('should return original response when status < 300', async () => { + const payload = createMiddlewarePayload({ + fallback: { es: 'en' }, + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/about' }); + const originalResponse = new Response('Content', { status: 200 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + + it('should redirect when status >= 300 and locale has fallback', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es', 'fr'], + defaultLocale: 'en', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/about' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/about'); + }); + + it('should return original response when no fallback configured', async () => { + const payload = createMiddlewarePayload({ + fallback: undefined, + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/about' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + + it('should return original response when locale not in fallback config', async () => { + const payload = createMiddlewarePayload({ + fallback: { es: 'en' }, + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/fr/about' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + }); + + describe('fallbackType: redirect', () => { + it('should redirect to fallback locale', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es', 'fr'], + defaultLocale: 'en', + fallback: { es: 'fr' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/blog/post' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/fr/blog/post'); + }); + + it('should remove default locale prefix with prefix-other-locales strategy', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + defaultLocale: 'en', + strategy: 'pathname-prefix-other-locales', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/about' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('Location'), '/about'); + }); + + it('should handle base path correctly', async () => { + const payload = createMiddlewarePayload({ + base: '/blog', + locales: ['en', 'es'], + defaultLocale: 'en', + strategy: 'pathname-prefix-other-locales', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/blog/es/post' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('Location'), '/blog/post'); + }); + + it('should preserve query string', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + defaultLocale: 'en', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/search?q=test' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('Location'), '/search?q=test'); + }); + + it('should handle 3xx status codes', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/page' }); + const originalResponse = new Response(null, { status: 301 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.status, 302); + }); + + it('should handle 4xx status codes', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/page' }); + const originalResponse = new Response(null, { status: 403 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.status, 302); + }); + + it('should handle 5xx status codes', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/page' }); + const originalResponse = new Response(null, { status: 500 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.status, 302); + }); + }); + + describe('fallbackType: rewrite', () => { + it('should rewrite to fallback locale', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es', 'fr'], + defaultLocale: 'en', + fallback: { es: 'fr' }, + fallbackType: 'rewrite', + }); + const fallbackFn = redirectToFallback(payload); + + // Mock context.rewrite + const context = { + ...createManualRoutingContext({ pathname: '/es/blog/post' }), + rewrite: async (path) => { + return new Response(null, { + status: 200, + headers: { 'X-Rewrite-Path': path }, + }); + }, + }; + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('X-Rewrite-Path'), '/fr/blog/post'); + }); + + it('should preserve query string in rewrite', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + defaultLocale: 'en', + fallback: { es: 'en' }, + fallbackType: 'rewrite', + }); + const fallbackFn = redirectToFallback(payload); + + const context = { + ...createManualRoutingContext({ pathname: '/es/search?q=test&lang=es' }), + rewrite: async (path) => { + return new Response(null, { + headers: { 'X-Rewrite-Path': path }, + }); + }, + }; + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('X-Rewrite-Path'), '/search?q=test&lang=es'); + }); + + it('should remove default locale prefix with prefix-other-locales strategy', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + defaultLocale: 'en', + strategy: 'pathname-prefix-other-locales', + fallback: { es: 'en' }, + fallbackType: 'rewrite', + }); + const fallbackFn = redirectToFallback(payload); + + const context = { + ...createManualRoutingContext({ pathname: '/es/about' }), + rewrite: async (path) => { + return new Response(null, { + headers: { 'X-Rewrite-Path': path }, + }); + }, + }; + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('X-Rewrite-Path'), '/about'); + }); + }); + + describe('locale extraction from pathname', () => { + it('should find locale in first segment', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/blog' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.notEqual(response, originalResponse); + assert.equal(response.status, 302); + }); + + it('should handle locale objects with path', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', { path: 'spanish', codes: ['es'] }], + defaultLocale: 'en', + fallback: { spanish: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/spanish/blog' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/blog'); + }); + + it('should handle fallback to non-default locale', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es', 'fr'], + defaultLocale: 'en', + strategy: 'pathname-prefix-always', + fallback: { es: 'fr' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/page' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('Location'), '/fr/page'); + }); + }); + + describe('edge cases', () => { + it('should handle root path with locale', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + defaultLocale: 'en', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + // When replacing /es with empty string, we get empty path + assert.equal(response.headers.get('Location'), ''); + }); + + it('should handle deep nested paths', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + defaultLocale: 'en', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/blog/2024/post/title' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('Location'), '/blog/2024/post/title'); + }); + + it('should handle base path without trailing slash', async () => { + const payload = createMiddlewarePayload({ + base: '/site', + locales: ['en', 'es'], + defaultLocale: 'en', + strategy: 'pathname-prefix-other-locales', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/site/es/page' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('Location'), '/site/page'); + }); + + it('should not fallback when locale is not found in path', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/blog/post' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + + it('should handle empty query string', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/page?' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + // context.url.search is empty for '?', so query string is not preserved + assert.equal(response.headers.get('Location'), '/page'); + }); + }); +}); diff --git a/packages/astro/test/units/i18n/test-helpers.js b/packages/astro/test/units/i18n/test-helpers.js index 2664ff7fa868..a6c6d197b82e 100644 --- a/packages/astro/test/units/i18n/test-helpers.js +++ b/packages/astro/test/units/i18n/test-helpers.js @@ -72,3 +72,97 @@ export function makeFallbackOptions({ base, }; } + +/** + * Creates a minimal mock APIContext for manual routing tests. + * + * This helper creates a mock context object that mimics Astro's APIContext + * with the essential properties needed for testing i18n manual routing functions + * like requestHasLocale, redirectToDefaultLocale, and notFound. + * + * @param {object} [options] - Configuration options for the mock context + * @param {string} [options.pathname='/'] - The pathname for the URL (e.g., '/en/blog') + * @param {string} [options.hostname='localhost'] - The hostname for the URL + * @param {string} [options.method='GET'] - The HTTP method for the request + * @param {string | undefined} [options.currentLocale] - The current locale from the context + * @returns {object} A mock APIContext object with url, request, currentLocale, and redirect method + * + * @example + * const context = createManualRoutingContext({ pathname: '/en/blog' }); + * const hasLocale = requestHasLocale(['en', 'es']); + * hasLocale(context); // true + */ +export function createManualRoutingContext({ + pathname = '/', + hostname = 'localhost', + method = 'GET', + currentLocale = undefined, + ...options +} = {}) { + const url = new URL(`http://${hostname}${pathname}`); + const request = new Request(url.toString(), { method }); + + return { + url, + request, + currentLocale, + redirect(path, status = 302) { + return new Response(null, { + status, + headers: { Location: path }, + }); + }, + ...options, + }; +} + +/** + * Creates a MiddlewarePayload for testing manual routing functions. + * + * This helper creates a payload object that matches the MiddlewarePayload type + * used by i18n manual routing functions like redirectToDefaultLocale and notFound. + * It provides sensible defaults for all required fields. + * + * @param {object} [options] - Configuration options for the middleware payload + * @param {string} [options.base=''] - The base path for the site (e.g., '/blog') + * @param {import('../../../src/types/public/config.js').Locales} [options.locales=['en', 'es']] - Array of locale strings or locale objects + * @param {'always' | 'never' | 'ignore'} [options.trailingSlash='ignore'] - Trailing slash behavior + * @param {'directory' | 'file'} [options.format='directory'] - Build output format + * @param {import('../../../dist/core/app/common.js').RoutingStrategies} [options.strategy='pathname-prefix-other-locales'] - i18n routing strategy + * @param {string} [options.defaultLocale='en'] - The default locale + * @param {Record | undefined} [options.domains] - Domain-to-locale mapping + * @param {Record | undefined} [options.fallback] - Fallback locale configuration + * @param {'redirect' | 'rewrite'} [options.fallbackType='redirect'] - Type of fallback behavior + * @returns {object} A MiddlewarePayload object + * + * @example + * const payload = createMiddlewarePayload({ + * base: '/blog', + * defaultLocale: 'en', + * locales: ['en', 'es', 'pt'] + * }); + * const redirect = redirectToDefaultLocale(payload); + */ +export function createMiddlewarePayload({ + base = '', + locales = ['en', 'es'], + trailingSlash = 'ignore', + format = 'directory', + strategy = 'pathname-prefix-other-locales', + defaultLocale = 'en', + domains = undefined, + fallback = undefined, + fallbackType = 'redirect', +} = {}) { + return { + base, + locales, + trailingSlash, + format, + strategy, + defaultLocale, + domains, + fallback, + fallbackType, + }; +} diff --git a/packages/astro/test/units/test-utils.js b/packages/astro/test/units/test-utils.js index abdaaed162db..bc7b72b721ad 100644 --- a/packages/astro/test/units/test-utils.js +++ b/packages/astro/test/units/test-utils.js @@ -204,3 +204,27 @@ export class SpyLogger { return new AstroIntegrationLogger(this.options, label); } } + +/** + * Creates a mock next() function for middleware testing. + * + * This helper creates a mock middleware next() function that returns a specified + * Response when called. The returned function has a `called` property that tracks + * whether the function has been invoked. + * + * @param {Response} [response] - The Response to return when next() is called + * @returns {(() => Promise)} An async function that returns the response + * + * @example + * const next = createMockNext(new Response('Page content')); + * const response = await next(); + * console.log(next.called); // true + */ +export function createMockNext(response = new Response('OK')) { + const nextFn = async () => { + nextFn.called = true; + return response; + }; + nextFn.called = false; + return nextFn; +} diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 9b68803231e1..bfd8927f5774 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -276,8 +276,8 @@ export default function createIntegration({ setAdapter({ name: '@astrojs/cloudflare', adapterFeatures: { - edgeMiddleware: false, buildOutput: 'server', + middlewareMode: 'classic', }, entrypointResolution: 'auto', previewEntrypoint: '@astrojs/cloudflare/entrypoints/preview', diff --git a/packages/astro/test/content-layer-markdoc.test.js b/packages/integrations/markdoc/test/content-layer.test.js similarity index 63% rename from packages/astro/test/content-layer-markdoc.test.js rename to packages/integrations/markdoc/test/content-layer.test.js index c5279b9e7522..2c2af3150d8f 100644 --- a/packages/astro/test/content-layer-markdoc.test.js +++ b/packages/integrations/markdoc/test/content-layer.test.js @@ -1,14 +1,16 @@ 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'; +import { parseHTML } from 'linkedom'; +import { loadFixture } from '../../../astro/test/test-utils.js'; -describe('Content layer markdoc', () => { +const root = new URL('./fixtures/content-layer/', import.meta.url); + +describe('Markdoc - Content Layer', () => { let fixture; before(async () => { fixture = await loadFixture({ - root: './fixtures/content-layer-markdoc/', + root, }); }); @@ -59,30 +61,30 @@ describe('Content layer markdoc', () => { /** @param {string} html */ function renderComponentsChecks(html) { - const $ = cheerio.load(html); - const h2 = $('h2'); - assert.equal(h2.text(), 'Post with components'); + const { document } = parseHTML(html); + const h2 = document.querySelector('h2'); + assert.equal(h2.textContent, 'Post with components'); // Renders custom shortcode component - const marquee = $('marquee'); + const marquee = document.querySelector('marquee'); assert.notEqual(marquee, null); - assert.equal(marquee.attr('data-custom-marquee'), ''); + assert.equal(marquee.hasAttribute('data-custom-marquee'), true); // Renders Astro Code component - const pre = $('pre'); + const pre = document.querySelector('pre'); assert.notEqual(pre, null); - assert.ok(pre.hasClass('github-dark')); - assert.ok(pre.hasClass('astro-code')); + assert.ok(pre.classList.contains('github-dark')); + assert.ok(pre.classList.contains('astro-code')); } /** @param {string} html */ function renderComponentsInsidePartialsChecks(html) { - const $ = cheerio.load(html); + const { document } = parseHTML(html); // renders Counter.tsx - const button = $('#counter'); - assert.equal(button.text(), '1'); + const button = document.querySelector('#counter'); + assert.equal(button.textContent, '1'); // renders DeeplyNested.astro - const deeplyNested = $('#deeply-nested'); - assert.equal(deeplyNested.text(), 'Deeply nested partial'); + const deeplyNested = document.querySelector('#deeply-nested'); + assert.equal(deeplyNested.textContent, 'Deeply nested partial'); } diff --git a/packages/astro/test/fixtures/content-layer-markdoc/astro.config.mjs b/packages/integrations/markdoc/test/fixtures/content-layer/astro.config.mjs similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/astro.config.mjs rename to packages/integrations/markdoc/test/fixtures/content-layer/astro.config.mjs diff --git a/packages/astro/test/fixtures/content-layer-markdoc/content/_nested.mdoc b/packages/integrations/markdoc/test/fixtures/content-layer/content/_nested.mdoc similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/content/_nested.mdoc rename to packages/integrations/markdoc/test/fixtures/content-layer/content/_nested.mdoc diff --git a/packages/astro/test/fixtures/content-layer-markdoc/content/blog/_counter.mdoc b/packages/integrations/markdoc/test/fixtures/content-layer/content/blog/_counter.mdoc similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/content/blog/_counter.mdoc rename to packages/integrations/markdoc/test/fixtures/content-layer/content/blog/_counter.mdoc diff --git a/packages/astro/test/fixtures/content-layer-markdoc/content/blog/with-components.mdoc b/packages/integrations/markdoc/test/fixtures/content-layer/content/blog/with-components.mdoc similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/content/blog/with-components.mdoc rename to packages/integrations/markdoc/test/fixtures/content-layer/content/blog/with-components.mdoc diff --git a/packages/astro/test/fixtures/content-layer-markdoc/markdoc.config.ts b/packages/integrations/markdoc/test/fixtures/content-layer/markdoc.config.ts similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/markdoc.config.ts rename to packages/integrations/markdoc/test/fixtures/content-layer/markdoc.config.ts diff --git a/packages/astro/test/fixtures/content-layer-markdoc/package.json b/packages/integrations/markdoc/test/fixtures/content-layer/package.json similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/package.json rename to packages/integrations/markdoc/test/fixtures/content-layer/package.json diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/components/Code.astro b/packages/integrations/markdoc/test/fixtures/content-layer/src/components/Code.astro similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/src/components/Code.astro rename to packages/integrations/markdoc/test/fixtures/content-layer/src/components/Code.astro diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/components/Counter.tsx b/packages/integrations/markdoc/test/fixtures/content-layer/src/components/Counter.tsx similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/src/components/Counter.tsx rename to packages/integrations/markdoc/test/fixtures/content-layer/src/components/Counter.tsx diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/components/CounterWrapper.astro b/packages/integrations/markdoc/test/fixtures/content-layer/src/components/CounterWrapper.astro similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/src/components/CounterWrapper.astro rename to packages/integrations/markdoc/test/fixtures/content-layer/src/components/CounterWrapper.astro diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/components/CustomMarquee.astro b/packages/integrations/markdoc/test/fixtures/content-layer/src/components/CustomMarquee.astro similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/src/components/CustomMarquee.astro rename to packages/integrations/markdoc/test/fixtures/content-layer/src/components/CustomMarquee.astro diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/components/DeeplyNested.astro b/packages/integrations/markdoc/test/fixtures/content-layer/src/components/DeeplyNested.astro similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/src/components/DeeplyNested.astro rename to packages/integrations/markdoc/test/fixtures/content-layer/src/components/DeeplyNested.astro diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/content.config.ts b/packages/integrations/markdoc/test/fixtures/content-layer/src/content.config.ts similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/src/content.config.ts rename to packages/integrations/markdoc/test/fixtures/content-layer/src/content.config.ts diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/pages/index.astro b/packages/integrations/markdoc/test/fixtures/content-layer/src/pages/index.astro similarity index 100% rename from packages/astro/test/fixtures/content-layer-markdoc/src/pages/index.astro rename to packages/integrations/markdoc/test/fixtures/content-layer/src/pages/index.astro diff --git a/packages/astro/test/fixtures/content-layer-rendering/.gitignore b/packages/integrations/mdx/test/fixtures/content-layer/.gitignore similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/.gitignore rename to packages/integrations/mdx/test/fixtures/content-layer/.gitignore diff --git a/packages/astro/test/fixtures/content-layer-rendering/astro.config.mjs b/packages/integrations/mdx/test/fixtures/content-layer/astro.config.mjs similarity index 77% rename from packages/astro/test/fixtures/content-layer-rendering/astro.config.mjs rename to packages/integrations/mdx/test/fixtures/content-layer/astro.config.mjs index 1aa22899ad59..0496d0a7d531 100644 --- a/packages/astro/test/fixtures/content-layer-rendering/astro.config.mjs +++ b/packages/integrations/mdx/test/fixtures/content-layer/astro.config.mjs @@ -4,6 +4,11 @@ import { fileURLToPath } from 'node:url'; export default defineConfig({ integrations: [mdx()], + image: { + service: { + entrypoint: 'astro/assets/services/noop' + } + }, vite: { resolve: { alias: { diff --git a/packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/I'm back!.mdx b/packages/integrations/mdx/test/fixtures/content-layer/content-outside-src-mdx/I'm back!.mdx similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/I'm back!.mdx rename to packages/integrations/mdx/test/fixtures/content-layer/content-outside-src-mdx/I'm back!.mdx diff --git a/packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/I'm back.jpg b/packages/integrations/mdx/test/fixtures/content-layer/content-outside-src-mdx/I'm back.jpg similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/I'm back.jpg rename to packages/integrations/mdx/test/fixtures/content-layer/content-outside-src-mdx/I'm back.jpg diff --git a/packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/iguana.mdx b/packages/integrations/mdx/test/fixtures/content-layer/content-outside-src-mdx/iguana.mdx similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/iguana.mdx rename to packages/integrations/mdx/test/fixtures/content-layer/content-outside-src-mdx/iguana.mdx diff --git a/packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/shuttle.jpg b/packages/integrations/mdx/test/fixtures/content-layer/content-outside-src-mdx/shuttle.jpg similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/shuttle.jpg rename to packages/integrations/mdx/test/fixtures/content-layer/content-outside-src-mdx/shuttle.jpg diff --git a/packages/astro/test/fixtures/content-layer-rendering/images/atlantis.avif b/packages/integrations/mdx/test/fixtures/content-layer/images/atlantis.avif similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/images/atlantis.avif rename to packages/integrations/mdx/test/fixtures/content-layer/images/atlantis.avif diff --git a/packages/astro/test/fixtures/content-layer-rendering/images/launch.webp b/packages/integrations/mdx/test/fixtures/content-layer/images/launch.webp similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/images/launch.webp rename to packages/integrations/mdx/test/fixtures/content-layer/images/launch.webp diff --git a/packages/astro/test/fixtures/content-layer-rendering/images/shuttle.jpg b/packages/integrations/mdx/test/fixtures/content-layer/images/shuttle.jpg similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/images/shuttle.jpg rename to packages/integrations/mdx/test/fixtures/content-layer/images/shuttle.jpg diff --git a/packages/astro/test/fixtures/content-layer-rendering/package.json b/packages/integrations/mdx/test/fixtures/content-layer/package.json similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/package.json rename to packages/integrations/mdx/test/fixtures/content-layer/package.json diff --git a/packages/astro/test/fixtures/content-layer-rendering/src/components/H2.astro b/packages/integrations/mdx/test/fixtures/content-layer/src/components/H2.astro similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/src/components/H2.astro rename to packages/integrations/mdx/test/fixtures/content-layer/src/components/H2.astro diff --git a/packages/astro/test/fixtures/content-layer-rendering/src/components/H3.astro b/packages/integrations/mdx/test/fixtures/content-layer/src/components/H3.astro similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/src/components/H3.astro rename to packages/integrations/mdx/test/fixtures/content-layer/src/components/H3.astro diff --git a/packages/astro/test/fixtures/content-layer-rendering/src/components/LayoutProp.astro b/packages/integrations/mdx/test/fixtures/content-layer/src/components/LayoutProp.astro similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/src/components/LayoutProp.astro rename to packages/integrations/mdx/test/fixtures/content-layer/src/components/LayoutProp.astro diff --git a/packages/astro/test/fixtures/content-layer-rendering/src/components/WithScripts.astro b/packages/integrations/mdx/test/fixtures/content-layer/src/components/WithScripts.astro similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/src/components/WithScripts.astro rename to packages/integrations/mdx/test/fixtures/content-layer/src/components/WithScripts.astro diff --git a/packages/astro/test/fixtures/content-layer-rendering/src/content.config.ts b/packages/integrations/mdx/test/fixtures/content-layer/src/content.config.ts similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/src/content.config.ts rename to packages/integrations/mdx/test/fixtures/content-layer/src/content.config.ts diff --git a/packages/astro/test/fixtures/content-layer-rendering/src/pages/reptiles/[slug].astro b/packages/integrations/mdx/test/fixtures/content-layer/src/pages/reptiles/[slug].astro similarity index 100% rename from packages/astro/test/fixtures/content-layer-rendering/src/pages/reptiles/[slug].astro rename to packages/integrations/mdx/test/fixtures/content-layer/src/pages/reptiles/[slug].astro diff --git a/packages/astro/test/content-layer-render.test.js b/packages/integrations/mdx/test/mdx-content-layer.test.js similarity index 71% rename from packages/astro/test/content-layer-render.test.js rename to packages/integrations/mdx/test/mdx-content-layer.test.js index fa743e719ca4..c6998b26f725 100644 --- a/packages/astro/test/content-layer-render.test.js +++ b/packages/integrations/mdx/test/mdx-content-layer.test.js @@ -1,21 +1,21 @@ import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; +import { loadFixture } from '../../../astro/test/test-utils.js'; describe('Content Layer MDX rendering dev', () => { - /** @type {import("./test-utils.js").Fixture} */ + /** @type {import("../../../astro/test/test-utils.js").Fixture} */ let fixture; let devServer; before(async () => { fixture = await loadFixture({ - root: './fixtures/content-layer-rendering/', + root: new URL('./fixtures/content-layer/', import.meta.url), }); devServer = await fixture.startDevServer(); }); after(async () => { - devServer?.stop(); + await devServer?.stop(); }); it('Render an MDX file', async () => { @@ -27,11 +27,11 @@ describe('Content Layer MDX rendering dev', () => { }); describe('Content Layer MDX rendering build', () => { - /** @type {import("./test-utils.js").Fixture} */ + /** @type {import("../../../astro/test/test-utils.js").Fixture} */ let fixture; before(async () => { fixture = await loadFixture({ - root: './fixtures/content-layer-rendering/', + root: new URL('./fixtures/content-layer/', import.meta.url), }); await fixture.build(); }); diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts index b73a1e86fbcc..7ca6aed53549 100644 --- a/packages/integrations/netlify/src/index.ts +++ b/packages/integrations/netlify/src/index.ts @@ -12,6 +12,7 @@ import type { AstroIntegrationLogger, HookParameters, IntegrationResolvedRoute, + MiddlewareMode, RouteToHeaders, } from 'astro'; import { build } from 'esbuild'; @@ -236,9 +237,14 @@ export interface NetlifyIntegrationConfig { cacheOnDemandPages?: boolean; /** - * If disabled, Middleware is applied to prerendered pages at build-time, and to on-demand-rendered pages at runtime. - * Only disable when your Middleware does not need to run on prerendered pages. - * If you use Middleware to implement authentication, redirects or similar things, you should should likely enabled it. + * Controls when and how middleware executes. + * - 'classic' (default): Middleware runs for prerendered pages at build time, and for SSR pages at request time. + * - 'edge': Middleware is deployed as a separate edge function. Recommended if you want to implement authentication, redirects, or similar things. + */ + middlewareMode?: MiddlewareMode; + + /** + * @deprecated Use `middlewareMode: 'edge'` instead. * * If enabled, Astro Middleware is deployed as an Edge Function and applies to all routes. * Caveat: Locals set in Middleware are not applied to prerendered pages, because they've been rendered at build-time and are served from the CDN. @@ -680,7 +686,10 @@ export default function netlifyIntegration( finalBuildOutput = buildOutput; - const useEdgeMiddleware = integrationConfig?.edgeMiddleware ?? false; + // Resolve middleware mode with backward compatibility + const middlewareMode = + integrationConfig?.middlewareMode ?? + (integrationConfig?.edgeMiddleware ? 'edge' : 'classic'); const useStaticHeaders = integrationConfig?.staticHeaders ?? false; setAdapter({ @@ -688,7 +697,7 @@ export default function netlifyIntegration( entrypointResolution: 'auto', serverEntrypoint: '@astrojs/netlify/ssr-function.js', adapterFeatures: { - edgeMiddleware: useEdgeMiddleware, + middlewareMode, staticHeaders: useStaticHeaders, }, supportedAstroFeatures: { diff --git a/packages/integrations/netlify/test/development/fixtures/primitives/astro.config.mjs b/packages/integrations/netlify/test/development/fixtures/primitives/astro.config.mjs index 6e87a4a5f0f5..5e0e483c49e7 100644 --- a/packages/integrations/netlify/test/development/fixtures/primitives/astro.config.mjs +++ b/packages/integrations/netlify/test/development/fixtures/primitives/astro.config.mjs @@ -4,7 +4,7 @@ import { defineConfig } from 'astro/config'; export default defineConfig({ output: 'server', adapter: netlify({ - edgeMiddleware: process.env.EDGE_MIDDLEWARE === 'true', + middlewareMode: process.env.EDGE_MIDDLEWARE === 'true' ? 'edge' : 'classic', imageCDN: process.env.DISABLE_IMAGE_CDN ? false : undefined, }), image: { diff --git a/packages/integrations/netlify/test/functions/edge-middleware.test.js b/packages/integrations/netlify/test/functions/edge-middleware.test.js index ae06a9f6f5e8..6d255f4dc7f9 100644 --- a/packages/integrations/netlify/test/functions/edge-middleware.test.js +++ b/packages/integrations/netlify/test/functions/edge-middleware.test.js @@ -7,7 +7,7 @@ describe( () => { const root = new URL('./fixtures/middleware/', import.meta.url); - describe('edgeMiddleware: false', () => { + describe('middlewareMode: classic', () => { let fixture; before(async () => { process.env.EDGE_MIDDLEWARE = 'false'; @@ -34,7 +34,7 @@ describe( }); }); - describe('edgeMiddleware: true', () => { + describe('middlewareMode: edge', () => { let fixture; before(async () => { process.env.EDGE_MIDDLEWARE = 'true'; diff --git a/packages/integrations/netlify/test/functions/fixtures/middleware/astro.config.mjs b/packages/integrations/netlify/test/functions/fixtures/middleware/astro.config.mjs index 0da6bf580408..08705fa4eb41 100644 --- a/packages/integrations/netlify/test/functions/fixtures/middleware/astro.config.mjs +++ b/packages/integrations/netlify/test/functions/fixtures/middleware/astro.config.mjs @@ -4,7 +4,7 @@ import { defineConfig } from 'astro/config'; export default defineConfig({ output: 'server', adapter: netlify({ - edgeMiddleware: process.env.EDGE_MIDDLEWARE === 'true', + middlewareMode: process.env.EDGE_MIDDLEWARE === 'true' ? 'edge' : 'classic', imageCDN: process.env.DISABLE_IMAGE_CDN ? false : undefined, }), image: { diff --git a/packages/integrations/netlify/test/hosted/hosted-astro-project/astro.config.mjs b/packages/integrations/netlify/test/hosted/hosted-astro-project/astro.config.mjs index 94cc00f7bd86..f39db4f25e06 100644 --- a/packages/integrations/netlify/test/hosted/hosted-astro-project/astro.config.mjs +++ b/packages/integrations/netlify/test/hosted/hosted-astro-project/astro.config.mjs @@ -5,7 +5,7 @@ import { defineConfig } from 'astro/config'; export default defineConfig({ output: 'server', adapter: netlify({ - edgeMiddleware: true, + middlewareMode: 'classic', }), image: { remotePatterns: [ diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index bdad07ba0d97..a76eb3609ab2 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -15,7 +15,7 @@ export function getAdapter({ staticHeaders }: Pick): A previewEntrypoint: '@astrojs/node/preview.js', adapterFeatures: { buildOutput: 'server', - edgeMiddleware: false, + middlewareMode: 'classic', staticHeaders, }, supportedAstroFeatures: { diff --git a/packages/integrations/vercel/src/index.ts b/packages/integrations/vercel/src/index.ts index a85c7dbedf4d..c7672aacdf8c 100644 --- a/packages/integrations/vercel/src/index.ts +++ b/packages/integrations/vercel/src/index.ts @@ -14,16 +14,17 @@ import type { AstroIntegrationLogger, HookParameters, IntegrationResolvedRoute, + MiddlewareMode, RouteToHeaders, } from 'astro'; import { AstroError } from 'astro/errors'; import { globSync } from 'tinyglobby'; -import type { RemotePattern } from './image/shared.js'; import { type DevImageService, getAstroImageConfig, getDefaultImageConfig, type VercelImageConfig, + type RemotePattern, } from './image/shared.js'; import { copyDependenciesToFunction } from './lib/nft.js'; import { escapeRegex, getRedirects } from './lib/redirects.js'; @@ -97,13 +98,13 @@ const SUPPORTED_NODE_VERSIONS: Record< }; function getAdapter({ - edgeMiddleware, + middlewareMode, skewProtection, buildOutput, staticHeaders, }: { buildOutput: 'server' | 'static'; - edgeMiddleware: NonNullable; + middlewareMode: NonNullable; skewProtection: boolean; staticHeaders: NonNullable; }): AstroAdapter { @@ -112,8 +113,8 @@ function getAdapter({ entrypointResolution: 'auto', serverEntrypoint: `${PACKAGE_NAME}/entrypoint`, adapterFeatures: { - edgeMiddleware, buildOutput, + middlewareMode, staticHeaders, }, supportedAstroFeatures: { @@ -161,7 +162,16 @@ export interface VercelServerlessConfig { /** Allows you to configure which image service to use in development when imageService is enabled. */ devImageService?: DevImageService; - /** Whether to create the Vercel Edge middleware from an Astro middleware in your code base. */ + /** + * Controls when and how middleware executes. + * - 'classic' (default): Middleware runs for prerendered pages at build time, and for SSR pages at request time. + * - 'edge': Middleware is deployed as a separate edge function. + */ + middlewareMode?: MiddlewareMode; + + /** + * @deprecated Use `middlewareMode: 'edge'` instead. + */ edgeMiddleware?: boolean; /** The maximum duration (in seconds) that Serverless Functions can run before timing out. See the [Vercel documentation](https://vercel.com/docs/functions/serverless-functions/runtimes#maxduration) for the default and maximum limit for your account plan. */ @@ -217,12 +227,16 @@ export default function vercelAdapter({ imageService, imagesConfig, devImageService = 'sharp', - edgeMiddleware = false, + middlewareMode, + edgeMiddleware, maxDuration, isr = false, skewProtection = process.env.VERCEL_SKEW_PROTECTION_ENABLED === '1', staticHeaders = false, }: VercelServerlessConfig = {}): AstroIntegration { + // Resolve middleware mode with backward compatibility + const resolvedMiddlewareMode = middlewareMode ?? (edgeMiddleware ? 'edge' : 'classic'); + if (maxDuration) { if (typeof maxDuration !== 'number') { throw new TypeError(`maxDuration must be a number`, { @@ -347,7 +361,7 @@ export default function vercelAdapter({ setAdapter( getAdapter({ buildOutput: _buildOutput, - edgeMiddleware, + middlewareMode: resolvedMiddlewareMode, skewProtection, staticHeaders: staticHeaders, }), @@ -355,7 +369,7 @@ export default function vercelAdapter({ } else { setAdapter( getAdapter({ - edgeMiddleware: false, + middlewareMode: resolvedMiddlewareMode, skewProtection, buildOutput: _buildOutput, staticHeaders: staticHeaders, diff --git a/packages/integrations/vercel/test/fixtures/middleware-with-edge-file/astro.config.mjs b/packages/integrations/vercel/test/fixtures/middleware-with-edge-file/astro.config.mjs index 0f09ab040cf4..cbc978711d78 100644 --- a/packages/integrations/vercel/test/fixtures/middleware-with-edge-file/astro.config.mjs +++ b/packages/integrations/vercel/test/fixtures/middleware-with-edge-file/astro.config.mjs @@ -3,7 +3,7 @@ import {defineConfig} from "astro/config"; export default defineConfig({ adapter: vercel({ - edgeMiddleware: true + middlewareMode: 'edge' }), output: 'server' }); diff --git a/packages/integrations/vercel/test/fixtures/middleware-without-edge-file/astro.config.mjs b/packages/integrations/vercel/test/fixtures/middleware-without-edge-file/astro.config.mjs index 0f09ab040cf4..cbc978711d78 100644 --- a/packages/integrations/vercel/test/fixtures/middleware-without-edge-file/astro.config.mjs +++ b/packages/integrations/vercel/test/fixtures/middleware-without-edge-file/astro.config.mjs @@ -3,7 +3,7 @@ import {defineConfig} from "astro/config"; export default defineConfig({ adapter: vercel({ - edgeMiddleware: true + middlewareMode: 'edge' }), output: 'server' }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc5852f73fb5..4e532f0dd98d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2799,21 +2799,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/content-layer-markdoc: - dependencies: - '@astrojs/markdoc': - specifier: workspace:* - version: link:../../../../integrations/markdoc - '@astrojs/preact': - specifier: workspace:* - version: link:../../../../integrations/preact - astro: - specifier: workspace:* - version: link:../../.. - preact: - specifier: ^10.28.4 - version: 10.28.4 - packages/astro/test/fixtures/content-layer-remark-plugins: dependencies: '@astrojs/mdx': @@ -2823,15 +2808,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/content-layer-rendering: - dependencies: - '@astrojs/mdx': - specifier: workspace:* - version: link:../../../../integrations/mdx - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/content-ssr-integration: dependencies: '@astrojs/mdx': @@ -5256,6 +5232,21 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/markdoc/test/fixtures/content-layer: + dependencies: + '@astrojs/markdoc': + specifier: workspace:* + version: link:../../.. + '@astrojs/preact': + specifier: workspace:* + version: link:../../../../preact + astro: + specifier: workspace:* + version: link:../../../../../astro + preact: + specifier: ^10.28.4 + version: 10.28.4 + packages/integrations/markdoc/test/fixtures/headings: dependencies: '@astrojs/markdoc': @@ -5521,6 +5512,15 @@ importers: specifier: ^7.3.1 version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2) + packages/integrations/mdx/test/fixtures/content-layer: + dependencies: + '@astrojs/mdx': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/mdx/test/fixtures/css-head-mdx: dependencies: '@astrojs/mdx':