From 6b21531c7671d9718baeb45b600832faf4fa7b2d Mon Sep 17 00:00:00 2001 From: Bob Means Date: Wed, 3 Jun 2026 13:09:49 -0400 Subject: [PATCH 01/39] replace virtual modules with generated files, formalize public helper API --- packages/apostrophe-astro/MIGRATION.md | 113 ++++ .../components/AposTemplate.astro | 2 +- .../components/AposWidget.astro | 2 +- .../components/layouts/AposEditLayout.astro | 2 +- .../components/layouts/AposLayout.astro | 2 +- packages/apostrophe-astro/design.md | 487 +++++++++++++++++ .../endpoints/renderWidget.astro | 2 +- .../apostrophe-astro/helpers/client/index.js | 10 + packages/apostrophe-astro/helpers/fetch.js | 4 +- .../apostrophe-astro/helpers/server/fetch.js | 143 +++++ .../apostrophe-astro/helpers/server/index.js | 13 + .../apostrophe-astro/helpers/server/url.js | 55 ++ .../helpers/universal/attachment.js | 283 ++++++++++ .../helpers/universal/index.js | 14 + .../helpers/universal/slug.js | 32 ++ .../helpers/universal/styles.js | 117 ++++ .../apostrophe-astro/helpers/universal/url.js | 122 +++++ packages/apostrophe-astro/helpers/url.js | 2 +- packages/apostrophe-astro/index.js | 51 +- .../apostrophe-astro/lib/aposPageFetch.js | 79 +-- packages/apostrophe-astro/lib/aposRequest.js | 2 +- packages/apostrophe-astro/lib/aposResponse.js | 2 +- .../lib/aposSetQueryParameter.js | 13 +- packages/apostrophe-astro/lib/static.js | 10 +- packages/apostrophe-astro/lib/util.js | 13 +- packages/apostrophe-astro/package.json | 19 +- .../tech-design-astro-integration-v2.md | 501 ++++++++++++++++++ .../vite/vite-plugin-apostrophe-config.js | 46 -- .../vite/vite-plugin-apostrophe-doctype.js | 52 -- ...vite-plugin-apostrophe-generated-config.js | 193 +++++++ 30 files changed, 2183 insertions(+), 203 deletions(-) create mode 100644 packages/apostrophe-astro/MIGRATION.md create mode 100644 packages/apostrophe-astro/design.md create mode 100644 packages/apostrophe-astro/helpers/client/index.js create mode 100644 packages/apostrophe-astro/helpers/server/fetch.js create mode 100644 packages/apostrophe-astro/helpers/server/index.js create mode 100644 packages/apostrophe-astro/helpers/server/url.js create mode 100644 packages/apostrophe-astro/helpers/universal/attachment.js create mode 100644 packages/apostrophe-astro/helpers/universal/index.js create mode 100644 packages/apostrophe-astro/helpers/universal/slug.js create mode 100644 packages/apostrophe-astro/helpers/universal/styles.js create mode 100644 packages/apostrophe-astro/helpers/universal/url.js create mode 100644 packages/apostrophe-astro/tech-design-astro-integration-v2.md delete mode 100644 packages/apostrophe-astro/vite/vite-plugin-apostrophe-config.js delete mode 100644 packages/apostrophe-astro/vite/vite-plugin-apostrophe-doctype.js create mode 100644 packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js diff --git a/packages/apostrophe-astro/MIGRATION.md b/packages/apostrophe-astro/MIGRATION.md new file mode 100644 index 0000000000..17f450c7c9 --- /dev/null +++ b/packages/apostrophe-astro/MIGRATION.md @@ -0,0 +1,113 @@ +# Migrating to @apostrophecms/apostrophe-astro v2 + +## Overview + +v2 ships two coordinated breaking changes: + +1. **Generated runtime files** replace Vite virtual modules for Astro 6 / Vite 7 compatibility. +2. **Public helper import paths** are formalized. `helpers/server`, `helpers/universal`, and `helpers/client` are the new stable entry points. `lib/` is now internal. + +Most projects need only the import-path changes described below. Integration options, component paths, and injected routes are unchanged. + +--- + +## What stays the same + +- `apostropheIntegration()` options (`aposHost`, `widgetsMapping`, `templatesMapping`, `onBeforeWidgetRender`, `staticBuild`, etc.) are unchanged. +- Component import paths (`@apostrophecms/apostrophe-astro/components/*`, `.../components/layouts/*`, `.../widgets/*`) are unchanged. +- Injected routes (`/apos-frontend/[...slug]`, `/api/v1/[...slug]`, etc.) are unchanged. + +--- + +## Required changes + +### 1. Update `lib/aposPageFetch.js` imports + +```js +// Before +import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js'; + +// After +import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; +``` + +### 2. Update `lib/aposSetQueryParameter.js` imports + +```js +// Before +import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js'; +// or +import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter'; + +// After +import { aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers/universal'; +``` + +### 3. Update `lib/util` imports + +```js +// Before +import { slugify } from '@apostrophecms/apostrophe-astro/lib/util'; +// or +import { slugify } from '@apostrophecms/apostrophe-astro/lib/util.js'; + +// After +import { slugify } from '@apostrophecms/apostrophe-astro/helpers/universal'; +``` + +### 4. Update `lib/aposStyles.js` and `lib/attachment.js` imports + +These files are no longer part of the public API. + +```js +// Before +import { stylesAttributes } from '@apostrophecms/apostrophe-astro/lib/aposStyles.js'; +import { getAttachmentUrl } from '@apostrophecms/apostrophe-astro/lib/attachment.js'; + +// After +import { stylesAttributes, getAttachmentUrl } from '@apostrophecms/apostrophe-astro/helpers/universal'; +``` + +--- + +## Deprecated shims (v2) + +The following `lib/` paths remain exported in v2 as compatibility shims. They will log `@deprecated` JSDoc notices but continue to work. Migrate before v3. + +| Old path | New path | +| --- | --- | +| `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js` | `@apostrophecms/apostrophe-astro/helpers/server` | +| `@apostrophecms/apostrophe-astro/lib/util` | `@apostrophecms/apostrophe-astro/helpers/universal` | +| `@apostrophecms/apostrophe-astro/lib/util.js` | `@apostrophecms/apostrophe-astro/helpers/universal` | +| `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter` | `@apostrophecms/apostrophe-astro/helpers/universal` | +| `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js` | `@apostrophecms/apostrophe-astro/helpers/universal` | + +--- + +## Unsupported usage that must be removed + +### Virtual module imports + +If your project imports `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` directly, those imports are unsupported and must be removed. These were private implementation details and have been replaced by generated files that are not part of the public API. + +### Unlisted `lib/` paths + +Any import of a `lib/` path not listed in the deprecated shims table above (e.g. `lib/aposRequest.js`, `lib/aposResponse.js`, `lib/format.js`, `lib/static.js`) will fail under the v2 exports map. These are internal modules with no public equivalent. If you need functionality that was only accessible via an internal path, open an issue to discuss adding a proper public helper. + +--- + +## Helper import contract + +| Import path | Use in | +| --- | --- | +| `@apostrophecms/apostrophe-astro/helpers/server` | Astro frontmatter, server endpoints, SSR routes, prerendering. Depends on generated config and Node.js built-ins — do not use in client scripts. | +| `@apostrophecms/apostrophe-astro/helpers/universal` | Utilities that work in both server and client contexts. Pure functions only — no generated config, no `process.env`, no Node.js built-ins. | +| `@apostrophecms/apostrophe-astro/helpers/client` | Reserved for future browser-only helpers. Empty in v2. | + +There is no top-level `helpers` barrel — always use one of the three explicit category paths. + +--- + +## Static build cache directory + +The static build cache has moved from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/`. Both directories live under `node_modules/` and require no `.gitignore` changes. This is an internal implementation detail with no user-facing impact. diff --git a/packages/apostrophe-astro/components/AposTemplate.astro b/packages/apostrophe-astro/components/AposTemplate.astro index 86b8c89442..4d3de65aab 100644 --- a/packages/apostrophe-astro/components/AposTemplate.astro +++ b/packages/apostrophe-astro/components/AposTemplate.astro @@ -1,5 +1,5 @@ --- -import { templates } from 'virtual:apostrophe-doctypes'; +import { templates } from 'apostrophe-astro-config/doctypes'; const { aposData } = Astro.props; diff --git a/packages/apostrophe-astro/components/AposWidget.astro b/packages/apostrophe-astro/components/AposWidget.astro index d36144da7f..301d76ccb2 100644 --- a/packages/apostrophe-astro/components/AposWidget.astro +++ b/packages/apostrophe-astro/components/AposWidget.astro @@ -1,5 +1,5 @@ --- -import { widgets } from "virtual:apostrophe-doctypes"; +import { widgets } from 'apostrophe-astro-config/doctypes'; const { widget, options, ...props } = Astro.props; const isEdit = widget._edit && Astro.url.searchParams.get("aposEdit"); diff --git a/packages/apostrophe-astro/components/layouts/AposEditLayout.astro b/packages/apostrophe-astro/components/layouts/AposEditLayout.astro index a6b9a6a525..975a5d801a 100644 --- a/packages/apostrophe-astro/components/layouts/AposEditLayout.astro +++ b/packages/apostrophe-astro/components/layouts/AposEditLayout.astro @@ -1,5 +1,5 @@ --- -import config from "virtual:apostrophe-config"; +import config from 'apostrophe-astro-config/config'; const { title, bodyClass, aposData } = Astro.props; const { viewTransitionWorkaround } = config; diff --git a/packages/apostrophe-astro/components/layouts/AposLayout.astro b/packages/apostrophe-astro/components/layouts/AposLayout.astro index 438aad5367..22305dd663 100644 --- a/packages/apostrophe-astro/components/layouts/AposLayout.astro +++ b/packages/apostrophe-astro/components/layouts/AposLayout.astro @@ -2,7 +2,7 @@ import AposRunLayout from "./AposRunLayout.astro"; import AposEditLayout from "./AposEditLayout.astro"; import AposRefreshLayout from "./AposRefreshLayout.astro"; -import config from 'virtual:apostrophe-config'; +import config from 'apostrophe-astro-config/config'; const { aposData } = Astro.props; diff --git a/packages/apostrophe-astro/design.md b/packages/apostrophe-astro/design.md new file mode 100644 index 0000000000..25360e7df8 --- /dev/null +++ b/packages/apostrophe-astro/design.md @@ -0,0 +1,487 @@ +## Background and Motivation + +The `@apostrophecms/apostrophe-astro` integration currently relies on two Vite virtual modules to surface runtime configuration and doctype mappings to Astro components and helper code: + +- `virtual:apostrophe-config`: exports resolved integration config, including host, prefix, header lists, and static build flags. +- `virtual:apostrophe-doctypes`: re-exports the user-supplied widgets and templates mapping modules, plus the optional `onBeforeWidgetRender` hook. + +These virtual modules are implemented with Vite plugin `resolveId` and `load` hooks using the `\0`-prefixed internal module ID convention. + +Vite virtual modules are a documented Vite plugin pattern, but this integration's current use is fragile in newer Astro/Vite versions because the generated modules are consumed across Astro's server, prerender, and client-oriented processing paths. Astro 6 uses Vite 7 and Vite's Environment API internally, which makes the old assumptions around one consistent module graph less reliable. + +The goal of v2 is to remove these runtime virtual-module dependencies and use the same breaking release to define the package's public import surface clearly. + +--- + +## v2 Scope + +This release makes two coordinated breaking changes: + +1. **Generated runtime files** replace the current Vite virtual modules. This is the Astro 6 compatibility fix. +2. **Public helper import paths** are formalized. Public helpers move behind stable helper entry points, while `lib/` becomes internal implementation. + +These changes ship together in v2. There is no phased rollout. Starter kits and README examples should be updated in the same release window so project code moves to the final public import paths immediately. + +--- + +## Problem: Why the Current Virtual Modules Break + +### Astro now processes code through multiple Vite environments + +Astro 6 runs on Vite 7 and uses Vite's Environment API internally. During dev and build, Astro code can be processed for different purposes: server rendering, prerendering, client output, and framework/runtime integration work. + +The current integration assumes that an import of `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` will always be resolved through the same plugin pipeline and with the same surrounding module graph. That assumption is now too weak. Helpers and internal library modules are server-only, while components and endpoints can be transformed in several Astro build contexts. + +### The doctype virtual module is the riskiest part + +`vite-plugin-apostrophe-doctype.js` currently calls `this.resolve()` inside its `load()` hook to resolve the user's `widgetsMapping`, `templatesMapping`, and optional `onBeforeWidgetRender` files. It then synthesizes a module that imports those resolved IDs. + +That creates a fragile chain: + +1. Resolve user mapping IDs during virtual module load. +2. Emit synthetic import statements from the virtual module. +3. Rely on those emitted imports resolving again in the environment that consumes the virtual module. + +This is sensitive to when and where `load()` runs. Failures show up as missing `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` modules, failed widget/template lookup, or helper code being unable to read integration config. + +### The `\0` prefix is not the root bug + +The `\0` prefix is still a normal Vite virtual-module convention. The issue is not that Vite has removed support for virtual modules. The issue is that this package is using virtual modules to carry generated integration state and resolved user imports across Astro/Vite environments where real files are a better fit. + +--- + +## Implementation Area 1: Generated Runtime Files + +### Core idea + +Replace the virtual module plugins with generated real files: + +1. During `astro:config:setup`, write generated runtime files under `node_modules/.apostrophe-astro-config`. This will suffice for both single site projects and multisite projects where a single Astro code instance is utilized. +2. Point internal imports at those files with Vite aliases. +3. Keep using Vite's resolver for user-supplied mapping paths so extensionless directories, project aliases, and package exports continue to work. + +Real files on disk are easier for Vite, Astro, and Node to reason about than synthetic modules returned from `load()`. They are also directly inspectable when debugging a project. + +### Generated file location + +Files are written to a dedicated hidden directory: + +```txt +node_modules/ + .apostrophe-astro-config/ + config.js + doctypes.js +``` + +The static build cache in `lib/static.js` is also refactored in this release from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/`. This is a private internal change with no user-facing impact. It makes the naming convention consistent across all three directories and removes any risk of the static setup wipe accidentally deleting generated runtime files. + +The three directories and their responsibilities: + +- `node_modules/.apostrophe-astro-static/`: temporary static build cache owned by `lib/static.js`. +- `node_modules/.apostrophe-astro-config/`: generated runtime modules owned by the integration setup hook. + +No `.gitignore` change is needed because both directories live under `node_modules/`. + +### Generated file content + +Each generated file includes a header: + +```js +// AUTO-GENERATED by @apostrophecms/apostrophe-astro. Do not edit. +// This file is regenerated on every dev server start and build. +``` + +`config.js` serializes the resolved integration config as a static ES module export: + +```js +export default { + aposHost: "http://localhost:3000", + aposPrefix: "", + includeResponseHeaders: ["set-cookie"], + excludeRequestHeaders: [], + viewTransitionWorkaround: false, + staticBuild: null +}; +``` + +This is the same object currently produced by the `load()` handler of `vite-plugin-apostrophe-config.js`. + +`doctypes.js` re-exports the user's mapping modules: + +```js +import { default as widgets } from "../../src/widgets/index.js"; +import { default as templates } from "../../src/templates/index.js"; +import onBeforeWidgetRenderHookFn from "../../src/hooks/onBeforeWidgetRender.js"; + +export { widgets, templates }; +export const onBeforeWidgetRenderHook = onBeforeWidgetRenderHookFn; +``` + +When no hook is provided, `onBeforeWidgetRenderHook` exports `undefined` as before. + +### Resolving user mapping files + +The rewrite must preserve the current behavior of `widgetsMapping`, `templatesMapping`, and `onBeforeWidgetRender`. + +The README documents values like: + +```js +widgetsMapping: './src/widgets', +templatesMapping: './src/templates', +onBeforeWidgetRender: './src/hooks/before-widget-render.js' +``` + +Those paths may be extensionless directories, explicit files, package subpaths, or project aliases. A simple `path.resolve(process.cwd(), mapping)` is not equivalent to the current behavior. + +Implementation should resolve these mappings with a small internal Vite plugin that runs before normal module transformation. This preserves Vite/Rollup resolution behavior while removing the runtime virtual modules. + +The integration's `astro:config:setup` hook should register a setup-only Vite plugin: + +```js +function vitePluginApostropheGeneratedConfig(options, resolvedConfig) { + return { + name: 'vite-plugin-apostrophe-generated-config', + enforce: 'pre', + async buildStart() { + const resolvedWidgets = await this.resolve(options.widgetsMapping); + const resolvedTemplates = await this.resolve(options.templatesMapping); + const resolvedHook = options.onBeforeWidgetRender + ? await this.resolve(options.onBeforeWidgetRender) + : null; + + await writeGeneratedRuntimeFiles({ + config: resolvedConfig, + widgetsId: resolvedWidgets?.id, + templatesId: resolvedTemplates?.id, + hookId: resolvedHook?.id + }); + } + }; +} +``` + +This plugin replaces the two existing virtual-module plugins. It does not return generated module source from `load()`. Its only job is to use Vite's resolver, then write real files under `node_modules/.apostrophe-astro-config/`. + +The generated files are then consumed through aliases registered in the same `astro:config:setup` call. All paths are computed from `config.root` — the project root Astro is operating on — rather than `process.cwd()`. This keeps paths correct regardless of where `astro` is invoked from, and is portable across project renames and moves since the paths are recomputed fresh on every dev server start and build: + +```js +const generatedDir = path.join(config.root, 'node_modules/.apostrophe-astro-config'); + +updateConfig({ + vite: { + plugins: [ + vitePluginApostropheGeneratedConfig(options, resolvedConfig) + ], + resolve: { + alias: { + 'apostrophe-astro-config/config': path.join(generatedDir, 'config.js'), + 'apostrophe-astro-config/doctypes': path.join(generatedDir, 'doctypes.js') + } + } + } +}); +``` + +The aliases point directly to the generated files. That keeps internal imports deterministic even though the files are generated during startup. + +If Vite starts resolving internal imports before `buildStart()` writes the files in dev mode, the fix is not a simple move to `configResolved()`. The `configResolved()` hook does not provide `this.resolve()`, so extensionless directory paths, project aliases, and package subpaths in `widgetsMapping` and `templatesMapping` would not resolve correctly there. The likely solution is a `configureServer` hook for dev mode or an explicit two-pass approach. The implementation must verify timing across dev startup, SSR rendering, and static build startup, because the hook execution order differs between those paths. + +If resolution fails, the integration should throw an error that names the specific option that failed, for example: + +```txt +Could not resolve apostrophe-astro widgetsMapping: ./src/widgets +``` + +### Import specifiers + +Internal imports are updated from virtual module specifiers to internal alias specifiers: + +| Old | New | +| --- | --- | +| `virtual:apostrophe-config` | `apostrophe-astro-config/config` | +| `virtual:apostrophe-doctypes` | `apostrophe-astro-config/doctypes` | + +These specifiers remain internal implementation details. They are not documented for project code. + +All internal files that currently import `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` are updated: + +`apostrophe-astro-config/config`: + +- `lib/aposRequest.js` +- `lib/aposResponse.js` +- `lib/aposPageFetch.js` +- `helpers/fetch.js` +- `helpers/url.js` +- `components/layouts/AposLayout.astro` +- `components/layouts/AposEditLayout.astro` + +`apostrophe-astro-config/doctypes`: + +- `components/AposWidget.astro` +- `components/AposTemplate.astro` +- `endpoints/renderWidget.astro` + +### Hidden virtual import audit + +The implementation must remove all internal imports of: + +- `virtual:apostrophe-config` +- `virtual:apostrophe-doctypes` + +Current known import sites are: + +- `helpers/fetch.js` +- `helpers/url.js` +- `lib/aposPageFetch.js` +- `lib/aposRequest.js` +- `lib/aposResponse.js` +- `components/layouts/AposLayout.astro` +- `components/layouts/AposEditLayout.astro` +- `components/AposWidget.astro` +- `components/AposTemplate.astro` +- `endpoints/renderWidget.astro` + +The implementation should also search for `virtual:` in the package before release to catch any hidden or newly introduced virtual imports. + +### Tradeoffs + +| | Virtual modules (current) | Generated runtime files (proposed) | +| --- | --- | --- | +| Astro 6 / Vite 7 environments | Fragile for this use case | Real files resolve consistently | +| Debuggability | Module content invisible on disk | Files inspectable in `node_modules/.apostrophe-astro-config/` | +| Static cache interaction | Not applicable | Kept separate; static cache refactored to `.apostrophe-astro-static/` | +| Complexity | Two virtual-module plugins with `resolveId`/`load` | File generation plus mapping resolution | +| Cold start | No disk I/O | Writes two small files at config time | + +--- + +## Implementation Area 2: Public Helper Architecture + +### Problem with the current state + +The integration currently has no `package.json` exports map. Consumers can import any file path that happens to exist in the package, including internal `lib/` files. The README also documents at least one `lib/` import, `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js`, so the current public surface is partly accidental and partly documented. + +This v2 release should be the last breaking change around project helper imports. The package needs a clear rule: + +- `helpers/` is public. +- `lib/` is internal. +- Helpers are separated into three explicit categories by import path: server-only, universal, and client-only. +- There is no top-level `helpers` barrel — consumers must choose the correct path deliberately. +- README examples and starter kits use only public helper paths. + +### Public helper entry points + +The package exports three helper entry points. There is no top-level `helpers` barrel — all imports must use an explicit path: + +- `@apostrophecms/apostrophe-astro/helpers/server`: server-only helpers for Astro frontmatter, routes, endpoints, prerendering, and SSR. These import from generated config, `process.env`, or Node.js built-ins unavailable in browsers. +- `@apostrophecms/apostrophe-astro/helpers/universal`: helpers that work in both server and client contexts. These are pure functions with no environment dependencies. +- `@apostrophecms/apostrophe-astro/helpers/client`: browser-only helpers that depend on browser APIs such as `window` or `document`. Currently reserved — no helpers are in this category yet — but the path and taxonomy are established now for future use. + +### Helper classification + +Server-only public helpers: + +| Export | Source | Reason | +| --- | --- | --- | +| `aposPageFetch` | `lib/aposPageFetch.js` | Fetches Apostrophe page data through server request/response helpers and generated config | +| `aposFetch` | `helpers/fetch.js` | Prepends backend host and reads generated config | +| `getAposHost` | `helpers/server-url.js` | Exposes backend host from generated config | +| `isStaticBuild` | `helpers/server-url.js` | Reads generated config | + +Universal public helpers: + +| Export | Source | Reason | +| --- | --- | --- | +| `buildPageUrl` | `helpers/universal/url.js` | Pure URL construction from provided Apostrophe data | +| `getFilterBaseUrl` | `helpers/universal/url.js` | Pure data inspection | +| `aposSetQueryParameter` | `helpers/universal/url.js` | Pure URL manipulation | +| `slugify` | `helpers/universal/slug.js` | Pure string utility | +| `stylesElements` | `helpers/universal/styles.js` | Pure widget data helper moved from `lib/aposStyles.js` | +| `stylesAttributes` | `helpers/universal/styles.js` | Pure widget data helper moved from `lib/aposStyles.js` | +| `getFocalPoint` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | +| `getAttachmentUrl` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | +| `getAttachmentSrcset` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | +| `getWidth` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | +| `getHeight` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | + +The `helpers/universal/index.js` barrel must not import any module that imports generated config. If `helpers/url.js` currently mixes server-only functions with pure URL functions, split it before wiring the universal barrel. + +### Proposed helper files + +The final helper structure uses folders with an `index.js` barrel per category. There is no top-level `helpers/index.js`: + +```txt +helpers/ + server/ + index.js # barrel: re-exports all server-only public helpers + fetch.js # aposFetch, aposPageFetch + url.js # getAposHost, isStaticBuild + universal/ + index.js # barrel: re-exports all universal public helpers + url.js # buildPageUrl, getFilterBaseUrl, aposSetQueryParameter + slug.js # slugify + styles.js # stylesElements, stylesAttributes (moved from lib/aposStyles.js) + attachment.js # getFocalPoint, getAttachmentUrl, etc. (moved from lib/attachment.js) + client/ + index.js # barrel: reserved for future browser-only helpers +``` + +### Package exports + +The exports map is intentionally restrictive — it exposes only what is considered public API. If a consumer needs something that is not listed, the right response is to deliver it through a proper public path in a subsequent release, not to access private code directly. This makes future internal refactoring possible without BC concerns. + +The map is a breaking change in the sense that any undocumented `lib/` imports in user projects will stop resolving. That is acceptable — those paths were never supported. + +The exports map must preserve the documented root import and documented component/widget imports: + +- `@apostrophecms/apostrophe-astro` +- `@apostrophecms/apostrophe-astro/helpers/server` +- `@apostrophecms/apostrophe-astro/helpers/universal` +- `@apostrophecms/apostrophe-astro/helpers/client` +- `@apostrophecms/apostrophe-astro/components/*` +- `@apostrophecms/apostrophe-astro/components/layouts/*` +- `@apostrophecms/apostrophe-astro/widgets/*` + +The integration also injects endpoint entry points by package path, so those must remain resolvable: + +- `@apostrophecms/apostrophe-astro/endpoints/aposProxy.js` +- `@apostrophecms/apostrophe-astro/endpoints/renderWidget.astro` + +### Deprecated shims + +The following `lib/` paths remain exported for backwards compatibility and receive JSDoc `@deprecated` notices pointing to `/helpers`: + +- `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js` +- `@apostrophecms/apostrophe-astro/lib/util` +- `@apostrophecms/apostrophe-astro/lib/util.js` +- `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter` +- `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js` + +Projects should migrate these imports to `@apostrophecms/apostrophe-astro/helpers/server` or `@apostrophecms/apostrophe-astro/helpers/universal`, depending on the helper. + +### Internal paths + +Other `lib/` paths are internal and are not listed in the exports map: + +- `lib/aposRequest.js` +- `lib/aposResponse.js` +- `lib/getAreaForApi.js` +- `lib/static.js` +- `lib/format.js` + +Projects importing these directly will need to move to `/helpers/server` or `/helpers/universal` where a public equivalent exists. + +`lib/aposStyles.js` and `lib/attachment.js` should be removed or reduced to temporary internal shims only if package internals still need them during the refactor. Their public implementations belong in `helpers/universal/styles.js` and `helpers/universal/attachment.js`. + +### README and starter-kit updates + +The README should explain the import contract and contribution rules directly: + +- Use `@apostrophecms/apostrophe-astro/helpers/server` in Astro frontmatter, endpoints, and other server-only code. +- Use `@apostrophecms/apostrophe-astro/helpers/universal` for utilities that work in both server and client contexts. +- Use `@apostrophecms/apostrophe-astro/helpers/client` for browser-only utilities that depend on `window`, `document`, or other browser APIs. +- There is no top-level `helpers` import — always use the explicit category path. +- Avoid importing from `@apostrophecms/apostrophe-astro/lib/*`; `lib/` is internal. + +The README must also document how to add a new helper so that developers and agents follow the same rules: + +- Classify the helper: does it import generated config, use `process.env`, or use a Node.js built-in unavailable in browsers? If yes, it is server-only (`helpers/server/`). Does it use browser APIs like `window` or `document`? If yes, it is client-only (`helpers/client/`). Otherwise it is universal (`helpers/universal/`). +- Add the implementation file to the correct category folder. +- Add a complete JSDoc block (`@param`, `@returns`, `@example`). +- Re-export the helper from that category's `index.js` barrel. + +Starter kits should be updated in the same release window so they no longer teach `lib/` imports. + +### TypeScript support + +All public helpers exported from `helpers/server/index.js`, `helpers/universal/index.js`, and `helpers/client/index.js` must have complete JSDoc annotations (`@param`, `@returns`, `@typedef` where needed). The package already uses JSDoc throughout — this extends that pattern consistently to the full public surface. Any existing JSDoc blocks should be inspected for correctness and full coverage. + +TypeScript declarations are generated from JSDoc using `tsc --declaration --allowJs --emitDeclarationOnly` and published with the package. The `package.json` `types` field points to the generated declarations. This gives TypeScript projects proper type checking and provides IntelliSense in VSCode for all projects regardless of whether they use TypeScript. + +--- + +## Migration Path for Existing Projects + +### Version bump + +This change ships as **v2.0.0**. + +Adding a `package.json` exports map is a breaking change because Node will reject imports of paths not listed in the map. A major version bump is required even if most projects do not need code changes. + +### What most projects need to do + +Most projects do not need to change integration configuration: + +- `apostropheIntegration()` options stay the same. +- `widgetsMapping`, `templatesMapping`, and `onBeforeWidgetRender` keep the same semantics. +- Component import paths stay the same. +- Injected routes stay the same. + +### Required and recommended changes + +Projects importing documented legacy `lib/` paths should migrate to the explicit helper entry points: + +```js +// Before +import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js'; +import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js'; + +// After +import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; +import { aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers/universal'; +``` + +Projects importing directly from `lib/aposStyles.js` or `lib/attachment.js` should also move to `/helpers`: + +```js +// Before +import { stylesAttributes } from '@apostrophecms/apostrophe-astro/lib/aposStyles.js'; +import { getAttachmentUrl } from '@apostrophecms/apostrophe-astro/lib/attachment.js'; + +// After +import { stylesAttributes, getAttachmentUrl } from '@apostrophecms/apostrophe-astro/helpers/universal'; +``` + +### Unsupported usage + +Projects importing `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` directly in their own Astro code were relying on private implementation details. Those imports are unsupported and must be removed. + +The generated `apostrophe-astro-config/*` specifiers are also private implementation details and should not be documented as public API. + +--- + +## Implementation Notes + +- Write the generated runtime files before internal code that imports them is transformed. +- Ensure generated files are recreated on every dev server start and build. +- Refactor static build cache from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/` in `lib/static.js`. +- Implement helper entry points and package exports in the same v2 release as the generated runtime files. +- Update README examples and starter kits in the same release window. +- Produce a `MIGRATION.md` file in the repo as a required v2 deliverable. The Migration Path section of this document is its basis. It should cover: all required import path changes, deprecated shim guidance, unsupported usage removal, and a confirmation that integration options and component paths are unchanged. +- Add focused tests or example-build coverage for: + - Astro 6 / Vite 7 dev startup. + - Astro 6 / Vite 7 static build. + - Extensionless directory mappings such as `./src/widgets`. + - Explicit file mappings. + - Missing mapping error messages. + - Optional `onBeforeWidgetRender`. +- Ensure all public helpers have complete JSDoc before running declaration generation. +- Run `tsc --declaration --allowJs --emitDeclarationOnly` as part of the release process and include generated `.d.ts` files in the published package via the `package.json` `types` field. + +--- + +## Main Risks + +- Generated files may need to exist earlier than `buildStart()` in Astro dev mode. +- Splitting `helpers/url.js` may accidentally change public behavior if not tested carefully. +- Adding an exports map may block undocumented imports in user projects. + +--- + +## Resolved Design Decisions + +- **`.gitignore`**: No special handling needed. Generated files live under `node_modules/`, which virtually all projects already ignore. +- **Generated file header**: Each generated file includes a header comment: `// AUTO-GENERATED by @apostrophecms/apostrophe-astro. Do not edit. This file is regenerated on every dev server start and build.` +- **Path computation**: All generated file paths and aliases are computed from `config.root`, not `process.cwd()`, ensuring portability across invocation contexts. +- **Static cache rename**: `lib/static.js` cache directory renamed from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/`. Internal change, no user impact. +- **TypeScript support**: Use complete JSDoc on all public helpers. Generate `.d.ts` via `tsc --declaration --allowJs --emitDeclarationOnly` and publish via `package.json` `types` field. Private generated module specifiers (`apostrophe-astro-config/config`, `apostrophe-astro-config/doctypes`) receive no published declarations as they are internal. diff --git a/packages/apostrophe-astro/endpoints/renderWidget.astro b/packages/apostrophe-astro/endpoints/renderWidget.astro index f9efcfc794..c8ddd19d48 100644 --- a/packages/apostrophe-astro/endpoints/renderWidget.astro +++ b/packages/apostrophe-astro/endpoints/renderWidget.astro @@ -2,7 +2,7 @@ import AposWidget from "../components/AposWidget.astro"; import aposRequest from "../lib/aposRequest.js"; import aposResponse from "../lib/aposResponse.js"; -import { onBeforeWidgetRenderHook } from "virtual:apostrophe-doctypes"; +import { onBeforeWidgetRenderHook } from 'apostrophe-astro-config/doctypes'; const request = aposRequest(Astro.request); const response = await aposResponse(request); diff --git a/packages/apostrophe-astro/helpers/client/index.js b/packages/apostrophe-astro/helpers/client/index.js new file mode 100644 index 0000000000..8c5b63ad83 --- /dev/null +++ b/packages/apostrophe-astro/helpers/client/index.js @@ -0,0 +1,10 @@ +/** + * Client-only public helpers for @apostrophecms/apostrophe-astro. + * + * Reserved for future browser-only helpers that depend on browser APIs + * such as `window` or `document`. No helpers are in this category yet. + * + * Do not add server-side or Node.js-dependent code here. + * + * @module @apostrophecms/apostrophe-astro/helpers/client + */ diff --git a/packages/apostrophe-astro/helpers/fetch.js b/packages/apostrophe-astro/helpers/fetch.js index 646c8c7cab..b55944b2a5 100644 --- a/packages/apostrophe-astro/helpers/fetch.js +++ b/packages/apostrophe-astro/helpers/fetch.js @@ -1,4 +1,4 @@ -import config from 'virtual:apostrophe-config'; +import config from 'apostrophe-astro-config/config'; import { getAposHost } from './url.js'; /** @@ -6,7 +6,7 @@ import { getAposHost } from './url.js'; * Astro code only** (`.astro` frontmatter, server endpoints, etc.). * * **Do NOT use in client-side code** — it depends on - * `virtual:apostrophe-config` and exposes the internal backend host. + * `apostrophe-astro-config/config` and exposes the internal backend host. * For browser requests use plain `fetch` with relative URLs * (e.g. `/api/v1/...`). * diff --git a/packages/apostrophe-astro/helpers/server/fetch.js b/packages/apostrophe-astro/helpers/server/fetch.js new file mode 100644 index 0000000000..0e2e454a9e --- /dev/null +++ b/packages/apostrophe-astro/helpers/server/fetch.js @@ -0,0 +1,143 @@ +import config from 'apostrophe-astro-config/config'; +import { getAposHost } from './url.js'; +import aposResponse from '../../lib/aposResponse.js'; +import aposRequest from '../../lib/aposRequest.js'; + +/** + * A transparent proxy around the native `fetch` API for **server-side + * Astro code only** (`.astro` frontmatter, server endpoints, etc.). + * + * **Do NOT use in client-side code** — it depends on + * `apostrophe-astro-config/config` and exposes the internal backend host. + * For browser requests use plain `fetch` with relative URLs + * (e.g. `/api/v1/...`). + * + * What it does on top of native `fetch`: + * - Prepends the Apostrophe backend host (`getAposHost()`) to relative + * URLs (paths starting with `/`). + * - Injects the `x-apos-static-base-url: 1` header during static builds + * so the backend returns path-only URLs in its responses. + * + * Accepts the same arguments as `fetch(input, init?)` and returns a + * standard `Response`. All `init` options (method, body, headers, signal, + * etc.) are preserved and merged. + * + * @param {string|URL|Request} input - URL or Request object. Relative + * paths (starting with `/`) are resolved against `getAposHost()`. + * @param {RequestInit} [init] - Optional fetch init options. + * @returns {Promise} + * + * @example + * ```astro + * --- + * import { aposFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; + * const response = await aposFetch('/api/v1/article?perPage=5'); + * const data = await response.json(); + * --- + * ``` + */ +export async function aposFetch(input, init) { + let url = input; + + if (typeof url === 'string' && url.startsWith('/')) { + url = getAposHost() + url; + } + const headers = new Headers(init?.headers); + if (config.staticBuild) { + headers.set('x-apos-static-base-url', '1'); + } + + return fetch(url, { + ...init || {}, + headers + }); +} + +/** + * Fetch a full Apostrophe page data object for the given Astro request. + * + * This is the primary entry point for SSR and static-build page routes. + * It wraps `aposRequest` and `aposResponse` to forward the incoming + * request to the Apostrophe backend and return the parsed JSON page data, + * including automatic handling of trailing-slash redirects. + * + * For static builds, use this inside `getStaticPaths` / your page + * frontmatter to retrieve the `aposData` prop. + * + * @param {Request} req - The incoming Astro request (`Astro.request`). + * @returns {Promise} The Apostrophe page data object. On error, + * returns an object with `errorFetchingPage` set to the caught error + * and `page.type` set to `'apos-fetch-error'`. + * + * @example + * ```astro + * --- + * import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; + * const aposData = await aposPageFetch(Astro.request); + * --- + * ``` + */ +export async function aposPageFetch(req) { + let aposData = {}; + try { + let request = aposRequest(req); + if (request.method === 'HEAD') { + request = new Request(request, { + method: 'GET' + }); + } + const response = await aposResponse(request); + let headers = response.headers; + aposData = await response.json(); + + // Apostrophe's external-front middleware returns redirects as JSON + // (e.g. { redirect: true, url: '/fr/', status: 302 }). When the + // redirect only adds or removes a trailing slash we should follow + // it internally rather than bouncing the browser — otherwise locale + // home pages like /fr/ cause an infinite redirect loop. + // Skip the site root "/" — it never needs this treatment. + // + // When a prefix is configured, Apostrophe returns redirect URLs + // without the prefix (it's a routing concern, not stored in page + // data). Strip the prefix from `from` so both sides compare on + // the same terms, then re-add it when constructing the retry URL. + if (aposData.redirect && aposData.url !== '/') { + const prefix = config.aposPrefix || ''; + let from = new URL(request.url).pathname.replace(/\/+$/, ''); + if (prefix && from.startsWith(prefix + '/')) { + from = from.slice(prefix.length); + } else if (prefix && from === prefix) { + from = '/'; + } + const to = (aposData.url || '').replace(/\/+$/, ''); + if (from === to) { + const retryUrl = prefix + aposData.url; + const retry = new Request(new URL(retryUrl, request.url), request); + const retryResponse = await aposResponse(retry); + headers = retryResponse.headers; + const retryData = await retryResponse.json(); + // Safety check: if the retry itself redirects to the same + // URL we just tried, we've hit an infinite redirect loop. + // Return an error instead of bouncing forever. + if (retryData.redirect && retryData.url === aposData.url) { + throw new Error( + `Infinite redirect detected: ${aposData.url} redirects back to itself` + ); + } + aposData = retryData; + } + } + + aposData.aposResponseHeaders = headers; + if (aposData.template === '@apostrophecms/page:notFound') { + aposData.notFound = true; + } + } catch (e) { + console.error('error:', e); + aposData.errorFetchingPage = e; + aposData.page = { + type: 'apos-fetch-error' + }; + } + return aposData; +} diff --git a/packages/apostrophe-astro/helpers/server/index.js b/packages/apostrophe-astro/helpers/server/index.js new file mode 100644 index 0000000000..03f6144683 --- /dev/null +++ b/packages/apostrophe-astro/helpers/server/index.js @@ -0,0 +1,13 @@ +/** + * Server-only public helpers for @apostrophecms/apostrophe-astro. + * + * Use these in Astro frontmatter, server endpoints, prerendering routes, + * and any other server-side code. Do not import this module from + * client-side scripts — it depends on generated integration config and + * Node.js internals unavailable in browsers. + * + * @module @apostrophecms/apostrophe-astro/helpers/server + */ + +export { aposFetch, aposPageFetch } from './fetch.js'; +export { getAposHost, isStaticBuild } from './url.js'; diff --git a/packages/apostrophe-astro/helpers/server/url.js b/packages/apostrophe-astro/helpers/server/url.js new file mode 100644 index 0000000000..0e4a53b2cf --- /dev/null +++ b/packages/apostrophe-astro/helpers/server/url.js @@ -0,0 +1,55 @@ +import config from 'apostrophe-astro-config/config'; + +/** + * Get the Apostrophe backend base URL, including the prefix when + * configured. + * + * Returns `config.aposHost + config.aposPrefix` — the full base URL + * for reaching the Apostrophe backend (e.g. + * `http://localhost:3000/my-repo`). Environment variable overrides + * (`APOS_HOST`, `APOS_PREFIX`) are resolved once at config time in + * the integration's `astro:config:setup` hook and stored in the + * generated config module — this function does no env lookups. + * + * Prefer `aposFetch` for API calls — use `getAposHost()` only when + * you need the raw URL string (e.g. for building non-fetch URLs). + * + * WARNING: not to be confused with "Public Host" — this is meant to + * be used only in Astro server-side code. Use relative URLs for + * client-side requests `/api/v1/...`. + * + * @returns {string} The backend base URL (e.g. `http://localhost:3000` + * or `http://localhost:3000/my-repo`). + * + * @example + * ```astro + * --- + * import { getAposHost } from '@apostrophecms/apostrophe-astro/helpers/server'; + * const host = getAposHost(); + * // e.g. 'http://localhost:3000' or 'http://localhost:3000/my-repo' + * --- + * ``` + */ +export function getAposHost() { + return config.aposHost + (config.aposPrefix || ''); +} + +/** + * Check whether the current build is a static build. + * + * Returns `true` when the Astro integration is configured for + * static output (e.g. `output: 'static'`). + * + * @returns {boolean} + * + * @example + * ```astro + * --- + * import { isStaticBuild } from '@apostrophecms/apostrophe-astro/helpers/server'; + * --- + * + * ``` + */ +export function isStaticBuild() { + return Boolean(config.staticBuild); +} diff --git a/packages/apostrophe-astro/helpers/universal/attachment.js b/packages/apostrophe-astro/helpers/universal/attachment.js new file mode 100644 index 0000000000..09eadc2424 --- /dev/null +++ b/packages/apostrophe-astro/helpers/universal/attachment.js @@ -0,0 +1,283 @@ +/** + * Attachment and image URL helpers. + * + * Pure utilities for working with ApostropheCMS attachment and image + * objects. These helpers have no environment dependencies and work in + * both server and client contexts. + */ + +const MISSING_ATTACHMENT_URL = '/images/missing-icon.svg'; + +/** + * Get the actual attachment object from either a full image object or + * a direct attachment. + * + * @param {object} attachmentObject - Either a full image object (with `_fields`) + * or a direct attachment object. + * @returns {object|null} + */ +function getAttachment(attachmentObject) { + if (!attachmentObject) return null; + + // If it's a full image object (has _fields), get its attachment + if (attachmentObject._fields) { + return attachmentObject.attachment; + } + + // If it's already an attachment or has nested attachment + return attachmentObject.attachment || attachmentObject; +} + +/** + * Check if attachment has multiple size variants. + * + * @param {object} attachmentObject - Either a full image object or direct attachment. + * @returns {boolean} True if the attachment has multiple sizes. + */ +function isSized(attachmentObject) { + const attachment = getAttachment(attachmentObject); + if (!attachment) return false; + + if (attachment._urls && typeof attachment._urls === 'object') { + return Object.keys(attachment._urls).length > 1; + } + + return false; +} + +/** + * Get focal point coordinates from an attachment or image object. + * + * Returns a CSS `object-position`-compatible string such as `"50% 75%"`. + * Falls back to `defaultValue` when no valid focal point is found. + * + * @param {object} attachmentObject - Either a full image object or direct attachment. + * @param {string} [defaultValue='center center'] - Value to return when no + * focal point is set. + * @returns {string} Focal point string for use in CSS (e.g. `"50% 50%"`). + * + * @example + * ```astro + * --- + * import { getFocalPoint } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * --- + * + * ``` + */ +export function getFocalPoint(attachmentObject, defaultValue = 'center center') { + if (!attachmentObject) return defaultValue; + + // Check _fields if it's from a relationship + if (attachmentObject._fields && + typeof attachmentObject._fields.x === 'number' && + attachmentObject._fields.x !== null && + typeof attachmentObject._fields.y === 'number' && + attachmentObject._fields.y !== null) { + return `${attachmentObject._fields.x}% ${attachmentObject._fields.y}%`; + } + + // Check attachment object directly if it's a direct attachment + const attachment = getAttachment(attachmentObject); + if (attachment && + typeof attachment.x === 'number' && + attachment.x !== null && + typeof attachment.y === 'number' && + attachment.y !== null) { + return `${attachment.x}% ${attachment.y}%`; + } + + return defaultValue; +} + +/** + * Get the width from an image object. + * + * Uses crop dimensions from `_fields` when available, otherwise falls + * back to the original attachment dimensions. + * + * @param {object} imageObject - Image object from ApostropheCMS. + * @returns {number|undefined} The width in pixels, or `undefined` if unavailable. + * + * @example + * ```astro + * --- + * import { getWidth } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * --- + * + * ``` + */ +export function getWidth(imageObject) { + // Use cropped width from _fields if available + if (imageObject?._fields?.width !== undefined && imageObject._fields.width !== null) { + return imageObject._fields.width; + } + // Fall back to original image width + return imageObject?.attachment?.width; +} + +/** + * Get the height from an image object. + * + * Uses crop dimensions from `_fields` when available, otherwise falls + * back to the original attachment dimensions. + * + * @param {object} imageObject - Image object from ApostropheCMS. + * @returns {number|undefined} The height in pixels, or `undefined` if unavailable. + * + * @example + * ```astro + * --- + * import { getHeight } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * --- + * + * ``` + */ +export function getHeight(imageObject) { + // Use cropped height from _fields if available + if (imageObject?._fields?.height !== undefined && imageObject._fields.height !== null) { + return imageObject._fields.height; + } + // Fall back to original image height + return imageObject?.attachment?.height; +} + +/** + * Get the crop parameters from an image object's `_fields`. + * + * @param {object} imageObject - The full image object from ApostropheCMS. + * @returns {{ left: number, top: number, width: number, height: number }|null} + */ +function getCrop(imageObject) { + if (imageObject?._fields && + typeof imageObject._fields.left === 'number' && + typeof imageObject._fields.top === 'number' && + typeof imageObject._fields.width === 'number' && + typeof imageObject._fields.height === 'number') { + return { + left: imageObject._fields.left, + top: imageObject._fields.top, + width: imageObject._fields.width, + height: imageObject._fields.height + }; + } + return null; +} + +/** + * Build the URL for an attachment with crop parameters and size. + * + * @param {string} baseUrl - The base URL for the attachment (without extension). + * @param {{ left: number, top: number, width: number, height: number }|null} crop + * @param {string} [size] - The size variant name. + * @param {string} extension - The file extension. + * @returns {string} + */ +function buildAttachmentUrl(baseUrl, crop, size, extension) { + let url = baseUrl; + + if (crop) { + url += `.${crop.left}.${crop.top}.${crop.width}.${crop.height}`; + } + + if (size && size !== 'original') { + url += `.${size}`; + } + + url += `.${extension}`; + return url; +} + +/** + * Get the URL for an attachment at an optional size variant. + * + * Handles the full-image object and direct attachment forms, crop + * parameters from `_fields`, and the "just-edited" state where the + * backend provides uncropped URLs. + * + * @param {object} imageObject - The full image object from ApostropheCMS. + * @param {object} [options={}] - Options. + * @param {string} [options.size='two-thirds'] - Size variant. One of: + * `'one-sixth'`, `'one-third'`, `'one-half'`, `'two-thirds'`, + * `'full'`, `'max'`, `'original'`. + * @param {string} [options.missingIcon] - Custom URL for missing attachments. + * @returns {string} The URL for the attachment. + * + * @example + * ```astro + * --- + * import { getAttachmentUrl } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * --- + * + * ``` + */ +export function getAttachmentUrl(imageObject, options = {}) { + const attachment = getAttachment(imageObject); + + if (!attachment) { + console.warn('Template warning: Missing attachment, using fallback icon'); + return options.missingIcon || MISSING_ATTACHMENT_URL; + } + + const size = options.size || 'two-thirds'; + + // During the just-edited state, _urls already contain crop parameters + if (attachment._urls?.uncropped) { + return attachment._urls[size] || attachment._urls.original; + } + + const crop = getCrop(imageObject); + + // If no crop, use the pre-generated URL + if (attachment._urls && !crop) { + return attachment._urls[size] || attachment._urls.original; + } + + // Derive the base URL from _urls if available + let baseUrl; + if (attachment._urls?.original) { + baseUrl = attachment._urls.original.replace(`.${attachment.extension}`, ''); + } + + return buildAttachmentUrl(baseUrl, crop, size, attachment.extension); +} + +/** + * Generate a `srcset` string for an image attachment. + * + * Returns an empty string when the attachment has no multiple size + * variants (e.g. SVG or PDF files). + * + * @param {object} attachmentObject - Either a full image object or direct attachment. + * @param {object} [options={}] - Options. + * @param {Array<{ name: string, width: number, height?: number }>} [options.sizes] + * Custom size descriptors. Defaults to the standard ApostropheCMS sizes. + * @returns {string} A `srcset` attribute value, or `''` if not applicable. + * + * @example + * ```astro + * --- + * import { getAttachmentSrcset } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * --- + * + * ``` + */ +export function getAttachmentSrcset(attachmentObject, options = {}) { + if (!attachmentObject || !isSized(attachmentObject)) { + return ''; + } + + const defaultSizes = [ + { name: 'one-sixth', width: 190, height: 350 }, + { name: 'one-third', width: 380, height: 700 }, + { name: 'one-half', width: 570, height: 700 }, + { name: 'two-thirds', width: 760, height: 760 }, + { name: 'full', width: 1140, height: 1140 }, + { name: 'max', width: 1600, height: 1600 } + ]; + + const sizes = options.sizes || defaultSizes; + + return sizes + .map(size => `${getAttachmentUrl(attachmentObject, { ...options, size: size.name })} ${size.width}w`) + .join(', '); +} diff --git a/packages/apostrophe-astro/helpers/universal/index.js b/packages/apostrophe-astro/helpers/universal/index.js new file mode 100644 index 0000000000..c81061ea04 --- /dev/null +++ b/packages/apostrophe-astro/helpers/universal/index.js @@ -0,0 +1,14 @@ +/** + * Universal public helpers for @apostrophecms/apostrophe-astro. + * + * These are pure functions with no environment dependencies — they work + * in both server and client contexts. No imports from generated config, + * `process.env`, or Node.js built-ins unavailable in browsers. + * + * @module @apostrophecms/apostrophe-astro/helpers/universal + */ + +export { buildPageUrl, getFilterBaseUrl, aposSetQueryParameter } from './url.js'; +export { slugify } from './slug.js'; +export { stylesElements, stylesAttributes } from './styles.js'; +export { getFocalPoint, getAttachmentUrl, getAttachmentSrcset, getWidth, getHeight } from './attachment.js'; diff --git a/packages/apostrophe-astro/helpers/universal/slug.js b/packages/apostrophe-astro/helpers/universal/slug.js new file mode 100644 index 0000000000..3d5934bafd --- /dev/null +++ b/packages/apostrophe-astro/helpers/universal/slug.js @@ -0,0 +1,32 @@ +import sluggo from 'sluggo'; +import deburr from 'lodash.deburr'; + +/** + * Apostrophe-compatible slugify helper. + * + * Converts a string to a URL-safe slug using the same algorithm as + * the Apostrophe CMS backend, ensuring consistent slugs across + * frontend and backend. + * + * @param {string} text - The string to slugify. + * @param {import('sluggo').Options & { stripAccents?: boolean }} [options] - Options. + * @param {boolean} [options.stripAccents] - When `true`, strip accents + * from characters (e.g. `é` → `e`). All other options are forwarded + * to the underlying `sluggo` library. + * @returns {string} The slugified string. + * + * @example + * ```js + * import { slugify } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * slugify('Hello World'); // 'hello-world' + * slugify('Ça va?', { stripAccents: true }); // 'ca-va' + * ``` + */ +export function slugify(text, options) { + const { stripAccents, ...opts } = options || {}; + const slug = sluggo(text, opts); + if (stripAccents) { + return deburr(slug); + } + return slug; +} diff --git a/packages/apostrophe-astro/helpers/universal/styles.js b/packages/apostrophe-astro/helpers/universal/styles.js new file mode 100644 index 0000000000..e08dc1166a --- /dev/null +++ b/packages/apostrophe-astro/helpers/universal/styles.js @@ -0,0 +1,117 @@ +/** + * Widget styles helpers. + * + * Pure utilities for reading and merging ApostropheCMS style option + * data (set via `@apostrophecms/styles`) from widget objects. These + * helpers have no environment dependencies and work in both server + * and client contexts. + */ + +/** + * Return the styles elements HTML string for a widget, or `null` if + * none are configured. + * + * The returned value is safe to pass to Astro's `set:html` directive. + * + * @param {object} widget - The widget object from ApostropheCMS. + * @returns {string|null} + * + * @example + * ```astro + * --- + * import { stylesElements } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * --- + * + * ``` + */ +export function stylesElements(widget) { + return widget._options?.aposStylesElements || null; +} + +/** + * Return a merged HTML attributes object combining the widget's + * ApostropheCMS styles attributes with any caller-supplied overrides. + * + * Classes are merged and deduplicated. Styles are concatenated. + * Any other attributes in `additionalAttrs` are merged in directly, + * with `undefined`/`null` values omitted. + * + * @param {object} widget - The widget object from ApostropheCMS. + * @param {object} [additionalAttrs={}] - Extra attributes to merge. + * @param {string|string[]} [additionalAttrs.class] - Additional CSS classes. + * @param {string} [additionalAttrs.style] - Additional inline style string. + * @returns {object} Merged attributes object suitable for Astro spread (`{...attrs}`). + * + * @example + * ```astro + * --- + * import { stylesAttributes } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * const attrs = stylesAttributes(widget, { class: 'my-extra-class' }); + * --- + *
+ * ``` + */ +export function stylesAttributes(widget, additionalAttrs = {}) { + // Separate class and style from other additional attributes + const { + class: additionalClasses, + style: additionalStyle, + ...otherAttrs + } = additionalAttrs; + + if (additionalClasses) { + if (!Array.isArray(additionalClasses) && typeof additionalClasses !== 'string') { + console.warn('class must be a string or an array of strings'); + } + if (Array.isArray(additionalClasses) && !additionalClasses.every(cls => typeof cls === 'string')) { + console.warn('class array must contain only strings'); + } + } + if (additionalStyle && typeof additionalStyle !== 'string') { + console.warn('style must be a string'); + } + + const stylesAttrs = widget._options?.aposStylesAttributes || {}; + const attrs = { ...stylesAttrs }; + + // Merge classes, keeping them unique + if (additionalClasses) { + const classSet = new Set(splitClasses(stylesAttrs.class)); + + const extraClasses = Array.isArray(additionalClasses) + ? additionalClasses + : splitClasses(additionalClasses); + + extraClasses.forEach(cls => classSet.add(cls)); + + if (classSet.size) { + attrs.class = Array.from(classSet).join(' '); + } + } + + // Merge styles + if (additionalStyle) { + attrs.style = + removeTrailingSemicolon(attrs.style) + + removeTrailingSemicolon(';' + additionalStyle); + } + + // Add other additional attributes + for (const [ key, value ] of Object.entries(otherAttrs)) { + if (value !== undefined && value !== null) { + attrs[key] = value; + } + } + + return attrs; +} + +function splitClasses(classes = '') { + return classes + .split(/\s+/) || [] + .filter(Boolean); +} + +function removeTrailingSemicolon(style = '') { + return style.replace(/;$/, ''); +} diff --git a/packages/apostrophe-astro/helpers/universal/url.js b/packages/apostrophe-astro/helpers/universal/url.js new file mode 100644 index 0000000000..5dcab3eb08 --- /dev/null +++ b/packages/apostrophe-astro/helpers/universal/url.js @@ -0,0 +1,122 @@ +// Mode-aware URL building utilities for Apostrophe piece index pages. +// +// Static URLs (@apostrophecms/url option `static: true`): +// /articles/page/2 +// /articles/categories/insights/page/2 +// +// Dynamic URLs (default): +// /articles?page=2 +// /articles?categories=insights&page=2 + +/** + * Get the effective base URL for the current filter context. + * + * If a filter choice is active, returns its `_url` (which already + * includes the filter segment in the correct format). Otherwise + * returns `page._url` — the plain index page URL. + * + * @param {object} aposData - The `aposData` object from `Astro.props`. + * @param {object} aposData.page - The page document (must have `_url`). + * @param {Array} [aposData.filters] - Filter definitions with choices. + * @returns {string} The base URL representing page 1 of the current + * filter context. + */ +export function getFilterBaseUrl(aposData) { + const { page, filters = [] } = aposData; + + for (const filter of filters) { + const activeChoice = filter.choices?.find((c) => c.active); + if (activeChoice?._url) { + return activeChoice._url; + } + } + + return page?._url || '/'; +} + +/** + * Build a pagination URL for a piece index page. + * + * Works in both static (path-based) and dynamic (query-string) modes. + * The mode is determined by `aposData.staticUrls`, which is set by the + * backend's `@apostrophecms/url` module when its `static` option is + * enabled. This ensures consistent URLs regardless of whether the + * Astro frontend runs in SSR or static build mode. + * + * The function determines the correct base URL by looking at the + * active filter choice (if any) and appends the page number in the + * appropriate format. + * + * Page 1 always returns the base URL without a page suffix. + * + * @param {object} aposData - The `aposData` object from `Astro.props`. + * Must contain `page` (with `_url`) and optionally `filters`. + * `aposData.staticUrls` controls path-based vs query-string URLs. + * @param {number} pageNum - The target page number (1-based). + * @returns {string} The URL for the given page. + * + * @example + * ```astro + * --- + * import { buildPageUrl } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * const { aposData } = Astro.props; + * --- + * Page 2 + * ``` + */ +export function buildPageUrl(aposData, pageNum) { + const baseUrl = getFilterBaseUrl(aposData); + + if (pageNum <= 1) { + return baseUrl; + } + + if (aposData.staticUrls) { + return `${baseUrl}/page/${pageNum}`; + } + + // Dynamic mode — append as query parameter, preserving any + // existing query string (e.g. ?categories=insights). + const url = new URL(baseUrl, 'http://localhost'); + url.searchParams.set('page', String(pageNum)); + return `${url.pathname}${url.search}`; +} + +/** + * Add, update or remove a named query parameter and return a new URL. + * This tool is not static URL aware. + * + * If `value` is `undefined`, `null` or empty string the parameter is + * removed from the query string. Internal Apostrophe parameters + * (`aposRefresh`, `aposMode`, `aposEdit`) are always stripped. + * + * Typically `Astro.url` is passed as the first argument. + * + * @param {URL|string} url - The current URL. + * @param {string} name - The query parameter name. + * @param {string|null|undefined} value - The value to set, or + * `null`/`undefined`/`''` to remove. + * @returns {URL} A new URL with the parameter applied. + * + * @example + * ```astro + * --- + * import { aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * const next = aposSetQueryParameter(Astro.url, 'page', '2'); + * --- + * Page 2 + * ``` + */ +export function aposSetQueryParameter(url, name, value) { + const newUrl = new URL(url); + // Internal query parameters not suitable for public facing URLs + newUrl.searchParams.delete('aposRefresh'); + newUrl.searchParams.delete('aposMode'); + newUrl.searchParams.delete('aposEdit'); + if ((value == null) || (value === '')) { + newUrl.searchParams.delete(name); + } else { + newUrl.searchParams.set(name, value); + } + return newUrl; +} diff --git a/packages/apostrophe-astro/helpers/url.js b/packages/apostrophe-astro/helpers/url.js index e5ab6e1424..0a5ad2e70d 100644 --- a/packages/apostrophe-astro/helpers/url.js +++ b/packages/apostrophe-astro/helpers/url.js @@ -1,4 +1,4 @@ -import config from 'virtual:apostrophe-config'; +import config from 'apostrophe-astro-config/config'; /** * Get the Apostrophe backend base URL, including the prefix when diff --git a/packages/apostrophe-astro/index.js b/packages/apostrophe-astro/index.js index 31f35e888a..b66f0772fa 100644 --- a/packages/apostrophe-astro/index.js +++ b/packages/apostrophe-astro/index.js @@ -1,5 +1,5 @@ -import { vitePluginApostropheDoctype } from './vite/vite-plugin-apostrophe-doctype.js'; -import { vitePluginApostropheConfig } from './vite/vite-plugin-apostrophe-config.js'; +import { vitePluginApostropheGeneratedConfig } from './vite/vite-plugin-apostrophe-generated-config.js'; +import path from 'node:path'; import { writeConfigCache, writeLiteralContent, @@ -141,25 +141,42 @@ export default function apostropheIntegration(options) { await writeConfigCache(staticBuild); } + // Build the integration config object that will be serialised + // into node_modules/.apostrophe-astro-config/config.js. + // forwardHeaders is normalised into includeResponseHeaders so + // the generated file has a single canonical field. + const integrationConfig = { + aposHost: resolvedAposHost, + aposPrefix, + includeResponseHeaders: + options.includeResponseHeaders || options.forwardHeaders || null, + excludeRequestHeaders: options.excludeRequestHeaders || null, + viewTransitionWorkaround: options.viewTransitionWorkaround || false, + staticBuild: isStaticBuild ? staticBuild : null + }; + + const generatedDir = path.join( + config.root, + 'node_modules', + '.apostrophe-astro-config' + ); + updateConfig({ vite: { plugins: [ - vitePluginApostropheDoctype( - options.widgetsMapping, - options.templatesMapping, - options.onBeforeWidgetRender - ), - vitePluginApostropheConfig({ - aposHost: resolvedAposHost, - forwardHeaders: options.forwardHeaders, - viewTransitionWorkaround: options.viewTransitionWorkaround, - includeResponseHeaders: options.includeResponseHeaders, - excludeRequestHeaders: options.excludeRequestHeaders, - staticBuild: isStaticBuild ? staticBuild : undefined, - aposPrefix - }), + vitePluginApostropheGeneratedConfig( + options, + integrationConfig, + config.root + ) ], - }, + resolve: { + alias: { + 'apostrophe-astro-config/config': path.join(generatedDir, 'config.js'), + 'apostrophe-astro-config/doctypes': path.join(generatedDir, 'doctypes.js') + } + } + } }); // Proxy routes are only needed for SSR — in static mode all data // is fetched at build time via getStaticPaths / aposPageFetch. diff --git a/packages/apostrophe-astro/lib/aposPageFetch.js b/packages/apostrophe-astro/lib/aposPageFetch.js index 099af16071..1a175ac33e 100644 --- a/packages/apostrophe-astro/lib/aposPageFetch.js +++ b/packages/apostrophe-astro/lib/aposPageFetch.js @@ -1,68 +1,11 @@ -import aposResponse from './aposResponse.js'; -import aposRequest from './aposRequest.js'; -import config from 'virtual:apostrophe-config'; - -export default async function aposPageFetch(req) { - let aposData = {}; - try { - let request = aposRequest(req); - if (request.method === 'HEAD') { - request = new Request(request, { - method: 'GET' - }); - } - const response = await aposResponse(request); - let headers = response.headers; - aposData = await response.json(); - - // Apostrophe's external-front middleware returns redirects as JSON - // (e.g. { redirect: true, url: '/fr/', status: 302 }). When the - // redirect only adds or removes a trailing slash we should follow - // it internally rather than bouncing the browser — otherwise locale - // home pages like /fr/ cause an infinite redirect loop. - // Skip the site root "/" — it never needs this treatment. - // - // When a prefix is configured, Apostrophe returns redirect URLs - // without the prefix (it's a routing concern, not stored in page - // data). Strip the prefix from `from` so both sides compare on - // the same terms, then re-add it when constructing the retry URL. - if (aposData.redirect && aposData.url !== '/') { - const prefix = config.aposPrefix || ''; - let from = new URL(request.url).pathname.replace(/\/+$/, ''); - if (prefix && from.startsWith(prefix + '/')) { - from = from.slice(prefix.length); - } else if (prefix && from === prefix) { - from = '/'; - } - const to = (aposData.url || '').replace(/\/+$/, ''); - if (from === to) { - const retryUrl = prefix + aposData.url; - const retry = new Request(new URL(retryUrl, request.url), request); - const retryResponse = await aposResponse(retry); - headers = retryResponse.headers; - const retryData = await retryResponse.json(); - // Safety check: if the retry itself redirects to the same - // URL we just tried, we've hit an infinite redirect loop. - // Return an error instead of bouncing forever. - if (retryData.redirect && retryData.url === aposData.url) { - throw new Error( - `Infinite redirect detected: ${aposData.url} redirects back to itself` - ); - } - aposData = retryData; - } - } - - aposData.aposResponseHeaders = headers; - if (aposData.template === '@apostrophecms/page:notFound') { - aposData.notFound = true; - } - } catch (e) { - console.error('error:', e); - aposData.errorFetchingPage = e; - aposData.page = { - type: 'apos-fetch-error' - }; - } - return aposData; -} +/** + * @deprecated Import from `@apostrophecms/apostrophe-astro/helpers/server` instead. + * + * ```js + * // Before + * import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js'; + * // After + * import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; + * ``` + */ +export { aposPageFetch as default } from '../helpers/server/fetch.js'; diff --git a/packages/apostrophe-astro/lib/aposRequest.js b/packages/apostrophe-astro/lib/aposRequest.js index 635fcedd43..3bc125cf6d 100644 --- a/packages/apostrophe-astro/lib/aposRequest.js +++ b/packages/apostrophe-astro/lib/aposRequest.js @@ -1,4 +1,4 @@ -import config from 'virtual:apostrophe-config'; +import config from 'apostrophe-astro-config/config'; export default function(req) { const request = new Request(req); diff --git a/packages/apostrophe-astro/lib/aposResponse.js b/packages/apostrophe-astro/lib/aposResponse.js index 1f9f676080..f4a09f9ac4 100644 --- a/packages/apostrophe-astro/lib/aposResponse.js +++ b/packages/apostrophe-astro/lib/aposResponse.js @@ -1,4 +1,4 @@ -import config from 'virtual:apostrophe-config'; +import config from 'apostrophe-astro-config/config'; import { request } from 'undici'; import zlib from 'zlib'; import { promisify } from 'util'; diff --git a/packages/apostrophe-astro/lib/aposSetQueryParameter.js b/packages/apostrophe-astro/lib/aposSetQueryParameter.js index e2ee04f7df..915d4a8c57 100644 --- a/packages/apostrophe-astro/lib/aposSetQueryParameter.js +++ b/packages/apostrophe-astro/lib/aposSetQueryParameter.js @@ -1,3 +1,12 @@ -// BC: re-export from helpers/url.js -import { aposSetQueryParameter } from '../helpers/url.js'; +/** + * @deprecated Import from `@apostrophecms/apostrophe-astro/helpers/universal` instead. + * + * ```js + * // Before + * import aposSetQueryParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js'; + * // After + * import { aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * ``` + */ +import { aposSetQueryParameter } from '../helpers/universal/url.js'; export default aposSetQueryParameter; diff --git a/packages/apostrophe-astro/lib/static.js b/packages/apostrophe-astro/lib/static.js index 24ee0897cc..11b6e00a65 100644 --- a/packages/apostrophe-astro/lib/static.js +++ b/packages/apostrophe-astro/lib/static.js @@ -2,7 +2,7 @@ import { writeFile, mkdir, readFile, readdir, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { bgGreen, black, blue, dim, green, yellow, red, getTimeStat, timestamp } from './format.js'; -const CACHE_DIR = join(process.cwd(), 'node_modules', '.apostrophe-astro'); +const CACHE_DIR = join(process.cwd(), 'node_modules', '.apostrophe-astro-static'); const CONFIG_CACHE = join(CACHE_DIR, '_config.json'); const ATTACHMENTS_CACHE = join(CACHE_DIR, '_attachments.json'); // Maximum number of concurrent attachment file downloads. @@ -81,7 +81,7 @@ export async function getLocales({ aposHost, aposExternalFrontKey }) { * @param {string} [config.locale] - The locale to fetch metadata for. * When omitted, the backend returns metadata for the default locale. * @param {object} [config.staticBuild] - Static build config from the - * integration (resolved from `virtual:apostrophe-config`). + * integration (resolved from `apostrophe-astro-config/config`). * @returns {Promise<{ paths: Array<{ params: { slug: string | undefined }, props: object }>, literalContent: Array, attachments: object | null }>} */ export async function getAllUrlMetadata(config) { @@ -186,9 +186,9 @@ export async function getAllUrlMetadata(config) { * 4. Deduplicates attachment metadata across locales and caches it * 5. Returns a flat array of `{ params, props }` entries * - * Static build configuration is read from `virtual:apostrophe-config` - * (injected by the integration plugin). Callers may override any - * value by passing it explicitly in `config`. + * Static build configuration is read from the cache written during + * `astro:config:setup`. Callers may override any value by passing + * it explicitly in `config`. * * @param {object} config * @param {string} config.aposHost - The Apostrophe backend URL. diff --git a/packages/apostrophe-astro/lib/util.js b/packages/apostrophe-astro/lib/util.js index 3bd92b288f..d3edbee51c 100644 --- a/packages/apostrophe-astro/lib/util.js +++ b/packages/apostrophe-astro/lib/util.js @@ -1,2 +1,11 @@ -// BC: re-export from helpers/slug.js -export { slugify } from '../helpers/slug.js'; +/** + * @deprecated Import from `@apostrophecms/apostrophe-astro/helpers/universal` instead. + * + * ```js + * // Before + * import { slugify } from '@apostrophecms/apostrophe-astro/lib/util'; + * // After + * import { slugify } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * ``` + */ +export { slugify } from '../helpers/universal/slug.js'; diff --git a/packages/apostrophe-astro/package.json b/packages/apostrophe-astro/package.json index eb8545a881..4afb6a9d0e 100644 --- a/packages/apostrophe-astro/package.json +++ b/packages/apostrophe-astro/package.json @@ -1,6 +1,6 @@ { "name": "@apostrophecms/apostrophe-astro", - "version": "1.12.0", + "version": "2.0.0", "type": "module", "description": "Apostrophe integration for Astro", "repository": { @@ -10,6 +10,23 @@ }, "homepage": "https://github.com/apostrophecms/apostrophe/tree/main/packages/apostrophe-astro#readme", "main": "index.js", + "types": "types/index.d.ts", + "exports": { + ".": "./index.js", + "./helpers/server": "./helpers/server/index.js", + "./helpers/universal": "./helpers/universal/index.js", + "./helpers/client": "./helpers/client/index.js", + "./components/*": "./components/*.astro", + "./components/layouts/*": "./components/layouts/*.astro", + "./widgets/*": "./widgets/*.astro", + "./endpoints/aposProxy.js": "./endpoints/aposProxy.js", + "./endpoints/renderWidget.astro": "./endpoints/renderWidget.astro", + "./lib/aposPageFetch.js": "./lib/aposPageFetch.js", + "./lib/util": "./lib/util.js", + "./lib/util.js": "./lib/util.js", + "./lib/aposSetQueryParameter": "./lib/aposSetQueryParameter.js", + "./lib/aposSetQueryParameter.js": "./lib/aposSetQueryParameter.js" + }, "author": "Apostrophe Technologies", "license": "MIT", "dependencies": { diff --git a/packages/apostrophe-astro/tech-design-astro-integration-v2.md b/packages/apostrophe-astro/tech-design-astro-integration-v2.md new file mode 100644 index 0000000000..0ed9e153b1 --- /dev/null +++ b/packages/apostrophe-astro/tech-design-astro-integration-v2.md @@ -0,0 +1,501 @@ +# Tech Design: Apostrophe-Astro Integration v2 + +**Status:** Draft +**Date:** 2026-06-02 + +--- + +## Background and Motivation + +The `@apostrophecms/apostrophe-astro` integration currently relies on two Vite virtual modules to surface runtime configuration and doctype mappings to Astro components and helper code: + +- `virtual:apostrophe-config`: exports resolved integration config, including host, prefix, header lists, and static build flags. +- `virtual:apostrophe-doctypes`: re-exports the user-supplied widgets and templates mapping modules, plus the optional `onBeforeWidgetRender` hook. + +These virtual modules are implemented with Vite plugin `resolveId` and `load` hooks using the `\0`-prefixed internal module ID convention. + +Vite virtual modules are a documented Vite plugin pattern, but this integration's current use is fragile in newer Astro/Vite versions because the generated modules are consumed across Astro's server, prerender, and client-oriented processing paths. Astro 6 uses Vite 7 and Vite's Environment API internally, which makes the old assumptions around one consistent module graph less reliable. + +The goal of v2 is to remove these runtime virtual-module dependencies and use the same breaking release to define the package's public import surface clearly. + +--- + +## v2 Scope + +This release makes two coordinated breaking changes: + +1. **Generated runtime files** replace the current Vite virtual modules. This is the Astro 6 compatibility fix. +2. **Public helper import paths** are formalized. Public helpers move behind stable helper entry points, while `lib/` becomes internal implementation. + +These changes ship together in v2. There is no phased rollout. Starter kits and README examples should be updated in the same release window so project code moves to the final public import paths immediately. + +--- + +## Problem: Why the Current Virtual Modules Break + +### Astro now processes code through multiple Vite environments + +Astro 6 runs on Vite 7 and uses Vite's Environment API internally. During dev and build, Astro code can be processed for different purposes: server rendering, prerendering, client output, and framework/runtime integration work. + +The current integration assumes that an import of `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` will always be resolved through the same plugin pipeline and with the same surrounding module graph. That assumption is now too weak. Helpers and internal library modules are server-only, while components and endpoints can be transformed in several Astro build contexts. + +### The doctype virtual module is the riskiest part + +`vite-plugin-apostrophe-doctype.js` currently calls `this.resolve()` inside its `load()` hook to resolve the user's `widgetsMapping`, `templatesMapping`, and optional `onBeforeWidgetRender` files. It then synthesizes a module that imports those resolved IDs. + +That creates a fragile chain: + +1. Resolve user mapping IDs during virtual module load. +2. Emit synthetic import statements from the virtual module. +3. Rely on those emitted imports resolving again in the environment that consumes the virtual module. + +This is sensitive to when and where `load()` runs. Failures show up as missing `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` modules, failed widget/template lookup, or helper code being unable to read integration config. + +### The `\0` prefix is not the root bug + +The `\0` prefix is still a normal Vite virtual-module convention. The issue is not that Vite has removed support for virtual modules. The issue is that this package is using virtual modules to carry generated integration state and resolved user imports across Astro/Vite environments where real files are a better fit. + +--- + +## Implementation Area 1: Generated Runtime Files + +### Core idea + +Replace the virtual module plugins with generated real files: + +1. During `astro:config:setup`, write generated runtime files under `node_modules/.apostrophe-astro-config`. ⚠️ **Needs follow-up before v2 ships**: verify behavior in multisite setups. In a monorepo with pnpm hoisting, multiple Astro projects could share a `node_modules` and write to the same path. Using `config.root` from the setup hook instead of `process.cwd()` may be sufficient, but the multisite scenario needs to be confirmed. If namespacing is required, a per-project subdirectory keyed on `config.root` is the likely approach. +2. Point internal imports at those files with Vite aliases. +3. Keep using Vite's resolver for user-supplied mapping paths so extensionless directories, project aliases, and package exports continue to work. + +Real files on disk are easier for Vite, Astro, and Node to reason about than synthetic modules returned from `load()`. They are also directly inspectable when debugging a project. + +### Generated file location + +Files are written to a dedicated hidden directory: + +```txt +node_modules/ + .apostrophe-astro-config/ + config.js + doctypes.js +``` + +The static build cache in `lib/static.js` is also refactored in this release from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/`. This is a private internal change with no user-facing impact. It makes the naming convention consistent across all three directories and removes any risk of the static setup wipe accidentally deleting generated runtime files. + +The three directories and their responsibilities: + +- `node_modules/.apostrophe-astro-static/`: temporary static build cache owned by `lib/static.js`. +- `node_modules/.apostrophe-astro-config/`: generated runtime modules owned by the integration setup hook. + +No `.gitignore` change is needed because both directories live under `node_modules/`. + +### Generated file content + +Each generated file includes a header: + +```js +// AUTO-GENERATED by @apostrophecms/apostrophe-astro. Do not edit. +// This file is regenerated on every dev server start and build. +``` + +`config.js` serializes the resolved integration config as a static ES module export: + +```js +export default { + aposHost: "http://localhost:3000", + aposPrefix: "", + includeResponseHeaders: ["set-cookie"], + excludeRequestHeaders: [], + viewTransitionWorkaround: false, + staticBuild: null +}; +``` + +This is the same object currently produced by the `load()` handler of `vite-plugin-apostrophe-config.js`. + +`doctypes.js` re-exports the user's mapping modules: + +```js +import { default as widgets } from "../../src/widgets/index.js"; +import { default as templates } from "../../src/templates/index.js"; +import onBeforeWidgetRenderHookFn from "../../src/hooks/onBeforeWidgetRender.js"; + +export { widgets, templates }; +export const onBeforeWidgetRenderHook = onBeforeWidgetRenderHookFn; +``` + +When no hook is provided, `onBeforeWidgetRenderHook` exports `undefined` as before. + +### Resolving user mapping files + +The rewrite must preserve the current behavior of `widgetsMapping`, `templatesMapping`, and `onBeforeWidgetRender`. + +The README documents values like: + +```js +widgetsMapping: './src/widgets', +templatesMapping: './src/templates', +onBeforeWidgetRender: './src/hooks/before-widget-render.js' +``` + +Those paths may be extensionless directories, explicit files, package subpaths, or project aliases. A simple `path.resolve(process.cwd(), mapping)` is not equivalent to the current behavior. + +Implementation should resolve these mappings with a small internal Vite plugin that runs before normal module transformation. This preserves Vite/Rollup resolution behavior while removing the runtime virtual modules. + +The integration's `astro:config:setup` hook should register a setup-only Vite plugin: + +```js +function vitePluginApostropheGeneratedConfig(options, resolvedConfig) { + return { + name: 'vite-plugin-apostrophe-generated-config', + enforce: 'pre', + async buildStart() { + const resolvedWidgets = await this.resolve(options.widgetsMapping); + const resolvedTemplates = await this.resolve(options.templatesMapping); + const resolvedHook = options.onBeforeWidgetRender + ? await this.resolve(options.onBeforeWidgetRender) + : null; + + await writeGeneratedRuntimeFiles({ + config: resolvedConfig, + widgetsId: resolvedWidgets?.id, + templatesId: resolvedTemplates?.id, + hookId: resolvedHook?.id + }); + } + }; +} +``` + +This plugin replaces the two existing virtual-module plugins. It does not return generated module source from `load()`. Its only job is to use Vite's resolver, then write real files under `node_modules/.apostrophe-astro-config/`. + +The generated files are then consumed through aliases registered in the same `astro:config:setup` call. All paths are computed from `config.root` — the project root Astro is operating on — rather than `process.cwd()`. This keeps paths correct regardless of where `astro` is invoked from, and is portable across project renames and moves since the paths are recomputed fresh on every dev server start and build: + +```js +const generatedDir = path.join(config.root, 'node_modules/.apostrophe-astro-config'); + +updateConfig({ + vite: { + plugins: [ + vitePluginApostropheGeneratedConfig(options, resolvedConfig) + ], + resolve: { + alias: { + 'apostrophe-astro-config/config': path.join(generatedDir, 'config.js'), + 'apostrophe-astro-config/doctypes': path.join(generatedDir, 'doctypes.js') + } + } + } +}); +``` + +The aliases point directly to the generated files. That keeps internal imports deterministic even though the files are generated during startup. + +If Vite starts resolving internal imports before `buildStart()` writes the files in dev mode, the fix is not a simple move to `configResolved()`. The `configResolved()` hook does not provide `this.resolve()`, so extensionless directory paths, project aliases, and package subpaths in `widgetsMapping` and `templatesMapping` would not resolve correctly there. The likely solution is a `configureServer` hook for dev mode or an explicit two-pass approach. The implementation must verify timing across dev startup, SSR rendering, and static build startup, because the hook execution order differs between those paths. + +If resolution fails, the integration should throw an error that names the specific option that failed, for example: + +```txt +Could not resolve apostrophe-astro widgetsMapping: ./src/widgets +``` + +### Import specifiers + +Internal imports are updated from virtual module specifiers to internal alias specifiers: + +| Old | New | +| --- | --- | +| `virtual:apostrophe-config` | `apostrophe-astro-config/config` | +| `virtual:apostrophe-doctypes` | `apostrophe-astro-config/doctypes` | + +These specifiers remain internal implementation details. They are not documented for project code. + +All internal files that currently import `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` are updated: + +`apostrophe-astro-config/config`: + +- `lib/aposRequest.js` +- `lib/aposResponse.js` +- `lib/aposPageFetch.js` +- `helpers/fetch.js` +- `helpers/url.js` +- `components/layouts/AposLayout.astro` +- `components/layouts/AposEditLayout.astro` + +`apostrophe-astro-config/doctypes`: + +- `components/AposWidget.astro` +- `components/AposTemplate.astro` +- `endpoints/renderWidget.astro` + +### Hidden virtual import audit + +The implementation must remove all internal imports of: + +- `virtual:apostrophe-config` +- `virtual:apostrophe-doctypes` + +Current known import sites are: + +- `helpers/fetch.js` +- `helpers/url.js` +- `lib/aposPageFetch.js` +- `lib/aposRequest.js` +- `lib/aposResponse.js` +- `components/layouts/AposLayout.astro` +- `components/layouts/AposEditLayout.astro` +- `components/AposWidget.astro` +- `components/AposTemplate.astro` +- `endpoints/renderWidget.astro` + +The implementation should also search for `virtual:` in the package before release to catch any hidden or newly introduced virtual imports. + +### Tradeoffs + +| | Virtual modules (current) | Generated runtime files (proposed) | +| --- | --- | --- | +| Astro 6 / Vite 7 environments | Fragile for this use case | Real files resolve consistently | +| Debuggability | Module content invisible on disk | Files inspectable in `node_modules/.apostrophe-astro-config/` | +| Static cache interaction | Not applicable | Kept separate; static cache refactored to `.apostrophe-astro-static/` | +| Complexity | Two virtual-module plugins with `resolveId`/`load` | File generation plus mapping resolution | +| Cold start | No disk I/O | Writes two small files at config time | + +--- + +## Implementation Area 2: Public Helper Architecture + +### Problem with the current state + +The integration currently has no `package.json` exports map. Consumers can import any file path that happens to exist in the package, including internal `lib/` files. The README also documents at least one `lib/` import, `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js`, so the current public surface is partly accidental and partly documented. + +This v2 release should be the last breaking change around project helper imports. The package needs a clear rule: + +- `helpers/` is public. +- `lib/` is internal. +- Helpers are separated into three explicit categories by import path: server-only, universal, and client-only. +- There is no top-level `helpers` barrel — consumers must choose the correct path deliberately. +- README examples and starter kits use only public helper paths. + +### Public helper entry points + +The package exports three helper entry points. There is no top-level `helpers` barrel — all imports must use an explicit path: + +- `@apostrophecms/apostrophe-astro/helpers/server`: server-only helpers for Astro frontmatter, routes, endpoints, prerendering, and SSR. These import from generated config, `process.env`, or Node.js built-ins unavailable in browsers. +- `@apostrophecms/apostrophe-astro/helpers/universal`: helpers that work in both server and client contexts. These are pure functions with no environment dependencies. +- `@apostrophecms/apostrophe-astro/helpers/client`: browser-only helpers that depend on browser APIs such as `window` or `document`. Currently reserved — no helpers are in this category yet — but the path and taxonomy are established now for future use. + +### Helper classification + +Server-only public helpers: + +| Export | Source | Reason | +| --- | --- | --- | +| `aposPageFetch` | `lib/aposPageFetch.js` | Fetches Apostrophe page data through server request/response helpers and generated config | +| `aposFetch` | `helpers/fetch.js` | Prepends backend host and reads generated config | +| `getAposHost` | `helpers/server-url.js` | Exposes backend host from generated config | +| `isStaticBuild` | `helpers/server-url.js` | Reads generated config | + +Universal public helpers: + +| Export | Source | Reason | +| --- | --- | --- | +| `buildPageUrl` | `helpers/universal/url.js` | Pure URL construction from provided Apostrophe data | +| `getFilterBaseUrl` | `helpers/universal/url.js` | Pure data inspection | +| `aposSetQueryParameter` | `helpers/universal/url.js` | Pure URL manipulation | +| `slugify` | `helpers/universal/slug.js` | Pure string utility | +| `stylesElements` | `helpers/universal/styles.js` | Pure widget data helper moved from `lib/aposStyles.js` | +| `stylesAttributes` | `helpers/universal/styles.js` | Pure widget data helper moved from `lib/aposStyles.js` | +| `getFocalPoint` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | +| `getAttachmentUrl` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | +| `getAttachmentSrcset` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | +| `getWidth` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | +| `getHeight` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | + +The `helpers/universal/index.js` barrel must not import any module that imports generated config. If `helpers/url.js` currently mixes server-only functions with pure URL functions, split it before wiring the universal barrel. + +### Proposed helper files + +The final helper structure uses folders with an `index.js` barrel per category. There is no top-level `helpers/index.js`: + +```txt +helpers/ + server/ + index.js # barrel: re-exports all server-only public helpers + fetch.js # aposFetch, aposPageFetch + url.js # getAposHost, isStaticBuild + universal/ + index.js # barrel: re-exports all universal public helpers + url.js # buildPageUrl, getFilterBaseUrl, aposSetQueryParameter + slug.js # slugify + styles.js # stylesElements, stylesAttributes (moved from lib/aposStyles.js) + attachment.js # getFocalPoint, getAttachmentUrl, etc. (moved from lib/attachment.js) + client/ + index.js # barrel: reserved for future browser-only helpers +``` + +### Package exports + +The exports map is intentionally restrictive — it exposes only what is considered public API. If a consumer needs something that is not listed, the right response is to deliver it through a proper public path in a subsequent release, not to access private code directly. This makes future internal refactoring possible without BC concerns. + +The map is a breaking change in the sense that any undocumented `lib/` imports in user projects will stop resolving. That is acceptable — those paths were never supported. + +The exports map must preserve the documented root import and documented component/widget imports: + +- `@apostrophecms/apostrophe-astro` +- `@apostrophecms/apostrophe-astro/helpers/server` +- `@apostrophecms/apostrophe-astro/helpers/universal` +- `@apostrophecms/apostrophe-astro/helpers/client` +- `@apostrophecms/apostrophe-astro/components/*` +- `@apostrophecms/apostrophe-astro/components/layouts/*` +- `@apostrophecms/apostrophe-astro/widgets/*` + +The integration also injects endpoint entry points by package path, so those must remain resolvable: + +- `@apostrophecms/apostrophe-astro/endpoints/aposProxy.js` +- `@apostrophecms/apostrophe-astro/endpoints/renderWidget.astro` + +### Deprecated shims + +The following `lib/` paths remain exported for backwards compatibility and receive JSDoc `@deprecated` notices pointing to `/helpers`: + +- `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js` +- `@apostrophecms/apostrophe-astro/lib/util` +- `@apostrophecms/apostrophe-astro/lib/util.js` +- `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter` +- `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js` + +Projects should migrate these imports to `@apostrophecms/apostrophe-astro/helpers/server` or `@apostrophecms/apostrophe-astro/helpers/universal`, depending on the helper. + +### Internal paths + +Other `lib/` paths are internal and are not listed in the exports map: + +- `lib/aposRequest.js` +- `lib/aposResponse.js` +- `lib/getAreaForApi.js` +- `lib/static.js` +- `lib/format.js` + +Projects importing these directly will need to move to `/helpers/server` or `/helpers/universal` where a public equivalent exists. + +`lib/aposStyles.js` and `lib/attachment.js` should be removed or reduced to temporary internal shims only if package internals still need them during the refactor. Their public implementations belong in `helpers/universal/styles.js` and `helpers/universal/attachment.js`. + +### README and starter-kit updates + +The README should explain the import contract and contribution rules directly: + +- Use `@apostrophecms/apostrophe-astro/helpers/server` in Astro frontmatter, endpoints, and other server-only code. +- Use `@apostrophecms/apostrophe-astro/helpers/universal` for utilities that work in both server and client contexts. +- Use `@apostrophecms/apostrophe-astro/helpers/client` for browser-only utilities that depend on `window`, `document`, or other browser APIs. +- There is no top-level `helpers` import — always use the explicit category path. +- Avoid importing from `@apostrophecms/apostrophe-astro/lib/*`; `lib/` is internal. + +The README must also document how to add a new helper so that developers and agents follow the same rules: + +- Classify the helper: does it import generated config, use `process.env`, or use a Node.js built-in unavailable in browsers? If yes, it is server-only (`helpers/server/`). Does it use browser APIs like `window` or `document`? If yes, it is client-only (`helpers/client/`). Otherwise it is universal (`helpers/universal/`). +- Add the implementation file to the correct category folder. +- Add a complete JSDoc block (`@param`, `@returns`, `@example`). +- Re-export the helper from that category's `index.js` barrel. + +Starter kits should be updated in the same release window so they no longer teach `lib/` imports. + +### TypeScript support + +All public helpers exported from `helpers/server/index.js`, `helpers/universal/index.js`, and `helpers/client/index.js` must have complete JSDoc annotations (`@param`, `@returns`, `@typedef` where needed). The package already uses JSDoc throughout — this extends that pattern consistently to the full public surface. + +TypeScript declarations are generated from JSDoc using `tsc --declaration --allowJs --emitDeclarationOnly` and published with the package. The `package.json` `types` field points to the generated declarations. This gives TypeScript projects proper type checking and provides IntelliSense in VSCode for all projects regardless of whether they use TypeScript. + +--- + +## Migration Path for Existing Projects + +### Version bump + +This change ships as **v2.0.0**. + +Adding a `package.json` exports map is a breaking change because Node will reject imports of paths not listed in the map. A major version bump is required even if most projects do not need code changes. + +### What most projects need to do + +Most projects do not need to change integration configuration: + +- `apostropheIntegration()` options stay the same. +- `widgetsMapping`, `templatesMapping`, and `onBeforeWidgetRender` keep the same semantics. +- Component import paths stay the same. +- Injected routes stay the same. + +### Required and recommended changes + +Projects importing documented legacy `lib/` paths should migrate to the explicit helper entry points: + +```js +// Before +import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js'; +import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js'; + +// After +import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; +import { aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers/universal'; +``` + +Projects importing directly from `lib/aposStyles.js` or `lib/attachment.js` should also move to `/helpers`: + +```js +// Before +import { stylesAttributes } from '@apostrophecms/apostrophe-astro/lib/aposStyles.js'; +import { getAttachmentUrl } from '@apostrophecms/apostrophe-astro/lib/attachment.js'; + +// After +import { stylesAttributes, getAttachmentUrl } from '@apostrophecms/apostrophe-astro/helpers/universal'; +``` + +### Unsupported usage + +Projects importing `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` directly in their own Astro code were relying on private implementation details. Those imports are unsupported and must be removed. + +The generated `apostrophe-astro-config/*` specifiers are also private implementation details and should not be documented as public API. + +--- + +## Implementation Notes + +- Write the generated runtime files before internal code that imports them is transformed. +- Ensure generated files are recreated on every dev server start and build. +- Refactor static build cache from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/` in `lib/static.js`. +- Implement helper entry points and package exports in the same v2 release as the generated runtime files. +- Update README examples and starter kits in the same release window. +- Produce a `MIGRATION.md` file in the repo as a required v2 deliverable. The Migration Path section of this document is its basis. It should cover: all required import path changes, deprecated shim guidance, unsupported usage removal, and a confirmation that integration options and component paths are unchanged. +- Add focused tests or example-build coverage for: + - Astro 6 / Vite 7 dev startup. + - Astro 6 / Vite 7 static build. + - Extensionless directory mappings such as `./src/widgets`. + - Explicit file mappings. + - Missing mapping error messages. + - Optional `onBeforeWidgetRender`. +- Ensure all public helpers have complete JSDoc before running declaration generation. +- Run `tsc --declaration --allowJs --emitDeclarationOnly` as part of the release process and include generated `.d.ts` files in the published package via the `package.json` `types` field. + +--- + +## Main Risks + +- Generated files may need to exist earlier than `buildStart()` in Astro dev mode. +- Splitting `helpers/url.js` may accidentally change public behavior if not tested carefully. +- Adding an exports map may block undocumented imports in user projects. + +--- + + +1. Is `buildStart()` the right Vite hook, or should this use `configResolved()` / another hook? +2. Are `helpers/server`, `helpers/client`, and `helpers` the right public import contract for v2? +3. Should any additional currently documented paths be included in the package exports map? +4. Should deprecated `lib/` shims remain for v2, or should v2 fully remove them? +5. Should any additional currently documented paths be included in the package exports map beyond those already listed? + +## Resolved Design Decisions + +- **`.gitignore`**: No special handling needed. Generated files live under `node_modules/`, which virtually all projects already ignore. +- **Generated file header**: Each generated file includes a header comment: `// AUTO-GENERATED by @apostrophecms/apostrophe-astro. Do not edit. This file is regenerated on every dev server start and build.` +- **Path computation**: All generated file paths and aliases are computed from `config.root`, not `process.cwd()`, ensuring portability across invocation contexts. +- **Static cache rename**: `lib/static.js` cache directory renamed from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/`. Internal change, no user impact. +- **TypeScript support**: Use complete JSDoc on all public helpers. Generate `.d.ts` via `tsc --declaration --allowJs --emitDeclarationOnly` and publish via `package.json` `types` field. Private generated module specifiers (`apostrophe-astro-config/config`, `apostrophe-astro-config/doctypes`) receive no published declarations as they are internal. diff --git a/packages/apostrophe-astro/vite/vite-plugin-apostrophe-config.js b/packages/apostrophe-astro/vite/vite-plugin-apostrophe-config.js deleted file mode 100644 index 37114b8530..0000000000 --- a/packages/apostrophe-astro/vite/vite-plugin-apostrophe-config.js +++ /dev/null @@ -1,46 +0,0 @@ -export function vitePluginApostropheConfig({ - aposHost, - forwardHeaders = null, - viewTransitionWorkaround, - includeResponseHeaders = null, - excludeRequestHeaders = null, - staticBuild = null, - aposPrefix = '' -} = {}) { - const virtualModuleId = "virtual:apostrophe-config"; - const resolvedVirtualModuleId = "\0" + virtualModuleId; - - // Use includeResponseHeaders if provided, fallback to forwardHeaders for BC - const headersToInclude = includeResponseHeaders || forwardHeaders; - - return { - name: "vite-plugin-apostrophe-config", - async resolveId(id) { - if (id === virtualModuleId) { - return resolvedVirtualModuleId; - } - }, - async load(id) { - if (id === resolvedVirtualModuleId) { - return ` - export default { - aposHost: ${JSON.stringify(aposHost)}, - aposPrefix: ${JSON.stringify(aposPrefix)} - ${headersToInclude ? `, - includeResponseHeaders: ${JSON.stringify(headersToInclude)}` : '' - } - ${excludeRequestHeaders ? `, - excludeRequestHeaders: ${JSON.stringify(excludeRequestHeaders)}` : '' - } - ${viewTransitionWorkaround ? `, - viewTransitionWorkaround: true` : '' - } - ${staticBuild ? `, - staticBuild: ${JSON.stringify(staticBuild)}` : '' - } - }` - ; - } - }, - }; -}; diff --git a/packages/apostrophe-astro/vite/vite-plugin-apostrophe-doctype.js b/packages/apostrophe-astro/vite/vite-plugin-apostrophe-doctype.js deleted file mode 100644 index 9a25e70b00..0000000000 --- a/packages/apostrophe-astro/vite/vite-plugin-apostrophe-doctype.js +++ /dev/null @@ -1,52 +0,0 @@ -export function vitePluginApostropheDoctype(widgetsMapping, templatesMapping, onBeforeWidgetRender = null) { - - const virtualModuleId = "virtual:apostrophe-doctypes"; - const resolvedVirtualModuleId = "\0" + virtualModuleId; - - return { - name: "vite-plugin-apostrophe-doctypes", - async resolveId(id) { - if (id === virtualModuleId) { - return resolvedVirtualModuleId; - } - }, - async load(id) { - if (id === resolvedVirtualModuleId) { - /** - * Handle registered doctypes - */ - const resolvedWidgetsId = await this.resolve(widgetsMapping); - const resolvedTemplatesId = await this.resolve(templatesMapping); - /** - * if the component cannot be resolved - */ - if (!resolvedWidgetsId || !resolvedTemplatesId) { - throw new Error( - `Widget or Templates mapping is missing.` - ); - } else { - /** - * if the component can be resolved, add it to the imports array - */ - - let hookImport = ''; - let hookExport = 'undefined'; - - if (onBeforeWidgetRender) { - const resolvedHookId = await this.resolve(onBeforeWidgetRender); - if (resolvedHookId) { - hookImport = `import onBeforeWidgetRenderHookFn from "${resolvedHookId.id}";`; - hookExport = 'onBeforeWidgetRenderHookFn'; - } - } - - return `import { default as widgets } from "${resolvedWidgetsId.id}"; - import { default as templates } from "${resolvedTemplatesId.id}"; - ${hookImport} - export { widgets, templates }; - export const onBeforeWidgetRenderHook = ${hookExport};` - } - } - }, - }; -} \ No newline at end of file diff --git a/packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js b/packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js new file mode 100644 index 0000000000..5cd4d3580c --- /dev/null +++ b/packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js @@ -0,0 +1,193 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +const GENERATED_HEADER = + '// AUTO-GENERATED by @apostrophecms/apostrophe-astro. Do not edit.\n' + + '// This file is regenerated on every dev server start and build.\n'; + +/** + * Write config.js and doctypes.js under + * `node_modules/.apostrophe-astro-config/`. + * + * @param {object} params + * @param {string} params.projectRoot - Astro project root (config.root). + * @param {object} params.integrationConfig - Resolved integration config to serialise. + * @param {string} params.widgetsId - Resolved absolute path to widgetsMapping module. + * @param {string} params.templatesId - Resolved absolute path to templatesMapping module. + * @param {string|null} params.hookId - Resolved absolute path to onBeforeWidgetRender, or null. + */ +async function writeGeneratedRuntimeFiles({ + projectRoot, + integrationConfig, + widgetsId, + templatesId, + hookId +}) { + const generatedDir = join(projectRoot, 'node_modules', '.apostrophe-astro-config'); + await mkdir(generatedDir, { recursive: true }); + + // ── config.js ────────────────────────────────────────────────────────────── + // Serialise the resolved integration config as a static ES module export. + // All keys are always present so consumer code can rely on their shape. + const configObj = { + aposHost: integrationConfig.aposHost ?? null, + aposPrefix: integrationConfig.aposPrefix ?? '', + includeResponseHeaders: integrationConfig.includeResponseHeaders ?? null, + excludeRequestHeaders: integrationConfig.excludeRequestHeaders ?? null, + viewTransitionWorkaround: integrationConfig.viewTransitionWorkaround ?? false, + staticBuild: integrationConfig.staticBuild ?? null + }; + + const configContent = + GENERATED_HEADER + + '\nexport default ' + + JSON.stringify(configObj, null, 2) + + ';\n'; + + await writeFile(join(generatedDir, 'config.js'), configContent); + + // ── doctypes.js ──────────────────────────────────────────────────────────── + // Re-export the user's mapping modules using relative paths from the + // generated directory so the file is portable across machines and moves. + function toRelative(absPath) { + let rel = relative(generatedDir, absPath).replace(/\\/g, '/'); + if (!rel.startsWith('.')) { + rel = './' + rel; + } + return rel; + } + + let hookImport = ''; + let hookExport = 'undefined'; + if (hookId) { + hookImport = `import onBeforeWidgetRenderHookFn from ${JSON.stringify(toRelative(hookId))};\n`; + hookExport = 'onBeforeWidgetRenderHookFn'; + } + + const doctypesContent = + GENERATED_HEADER + + '\n' + + `import { default as widgets } from ${JSON.stringify(toRelative(widgetsId))};\n` + + `import { default as templates } from ${JSON.stringify(toRelative(templatesId))};\n` + + hookImport + + '\nexport { widgets, templates };\n' + + `export const onBeforeWidgetRenderHook = ${hookExport};\n`; + + await writeFile(join(generatedDir, 'doctypes.js'), doctypesContent); +} + +/** + * Extract the resolved ID string from a Vite resolver result. + * Vite resolvers may return a string or an object with an `id` property. + * + * @param {string|{id:string}|null|undefined} result + * @returns {string|null} + */ +function extractId(result) { + if (!result) return null; + if (typeof result === 'string') return result; + return result.id ?? null; +} + +/** + * Vite plugin that generates real runtime files to replace the two + * old virtual-module plugins (`vite-plugin-apostrophe-config` and + * `vite-plugin-apostrophe-doctype`). + * + * Files are written to `node_modules/.apostrophe-astro-config/` and + * consumed through Vite aliases registered by the integration's + * `astro:config:setup` hook. + * + * Timing strategy + * ─────────────── + * • Dev mode – `configureServer` returns a post-middleware hook that + * runs after Vite's plugin container is fully initialised but before + * any request is served, giving us access to `server.pluginContainer` + * for resolution. + * • Build mode – `buildStart` has `this.resolve()` available and runs + * before any module transformation begins. + * + * A `filesWritten` flag prevents redundant disk writes if both hooks + * happen to fire in the same process. + * + * @param {import('../index.js').ApostropheIntegrationOptions} options + * @param {object} integrationConfig - Resolved integration config to serialise. + * @param {string} projectRoot - Astro project root (`config.root`). + * @returns {import('vite').Plugin} + */ +export function vitePluginApostropheGeneratedConfig(options, integrationConfig, projectRoot) { + let filesWritten = false; + + /** + * Resolve user mapping paths then write the generated files. + * + * @param {(id: string) => Promise} resolveId + */ + async function resolveAndWrite(resolveId) { + const rawWidgets = await resolveId(options.widgetsMapping); + const widgetsId = extractId(rawWidgets); + if (!widgetsId) { + throw new Error( + `Could not resolve apostrophe-astro widgetsMapping: ${options.widgetsMapping}` + ); + } + + const rawTemplates = await resolveId(options.templatesMapping); + const templatesId = extractId(rawTemplates); + if (!templatesId) { + throw new Error( + `Could not resolve apostrophe-astro templatesMapping: ${options.templatesMapping}` + ); + } + + let hookId = null; + if (options.onBeforeWidgetRender) { + const rawHook = await resolveId(options.onBeforeWidgetRender); + hookId = extractId(rawHook); + if (!hookId) { + throw new Error( + `Could not resolve apostrophe-astro onBeforeWidgetRender: ${options.onBeforeWidgetRender}` + ); + } + } + + await writeGeneratedRuntimeFiles({ + projectRoot, + integrationConfig, + widgetsId, + templatesId, + hookId + }); + + filesWritten = true; + } + + return { + name: 'vite-plugin-apostrophe-generated-config', + enforce: 'pre', + + // ── Dev mode ────────────────────────────────────────────────────────────── + // The function returned from configureServer runs after all middlewares + // are installed, before the server begins handling requests. At that + // point server.pluginContainer is fully initialised and can resolve IDs. + configureServer(server) { + return async () => { + if (!filesWritten) { + await resolveAndWrite((id) => + server.pluginContainer.resolveId(id, undefined, { ssr: true }) + ); + } + }; + }, + + // ── Build mode ──────────────────────────────────────────────────────────── + // buildStart provides this.resolve() and runs before any module is + // transformed, so the generated files exist when aliases are first hit. + async buildStart() { + if (!filesWritten) { + const self = this; + await resolveAndWrite((id) => self.resolve(id)); + } + } + }; +} From 9048c2ec4447d1f2dfbc1bdf9eeb144af75d43ee Mon Sep 17 00:00:00 2001 From: Bob Means Date: Wed, 3 Jun 2026 13:11:57 -0400 Subject: [PATCH 02/39] Add changeset --- .changeset/six-socks-design.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/six-socks-design.md diff --git a/.changeset/six-socks-design.md b/.changeset/six-socks-design.md new file mode 100644 index 0000000000..1f49bab3f6 --- /dev/null +++ b/.changeset/six-socks-design.md @@ -0,0 +1,16 @@ +--- +"@apostrophecms/apostrophe-astro": major +--- + +- Replace vite-plugin-apostrophe-config and vite-plugin-apostrophe-doctype with + vite/vite-plugin-apostrophe-generated-config.js, which writes real files to + node_modules/.apostrophe-astro-config/ (config.js, doctypes.js) +- Register Vite aliases for apostrophe-astro-config/config and /doctypes +- Update all 10 internal virtual: imports to alias specifiers +- Rename static build cache dir to node_modules/.apostrophe-astro-static/ +- Add helpers/server/ (aposFetch, aposPageFetch, getAposHost, isStaticBuild) +- Add helpers/universal/ (URL, slug, styles, attachment helpers) +- Add helpers/client/index.js (reserved) +- Reduce lib/aposPageFetch.js, lib/util.js, lib/aposSetQueryParameter.js to deprecated shims +- Add package.json exports map; bump version to 2.0.0 +- Add MIGRATION.md From f1333cf24c88917d9edf137ae3fd57f31219ed81 Mon Sep 17 00:00:00 2001 From: Bob Means Date: Thu, 4 Jun 2026 05:38:23 -0400 Subject: [PATCH 03/39] Helpers cleanup --- packages/apostrophe-astro/helpers/fetch.js | 52 ----- packages/apostrophe-astro/helpers/index.js | 3 - .../apostrophe-astro/helpers/server/index.js | 1 + .../apostrophe-astro/helpers/server/static.js | 9 + packages/apostrophe-astro/helpers/slug.js | 19 -- packages/apostrophe-astro/helpers/url.js | 179 ------------------ 6 files changed, 10 insertions(+), 253 deletions(-) delete mode 100644 packages/apostrophe-astro/helpers/fetch.js delete mode 100644 packages/apostrophe-astro/helpers/index.js create mode 100644 packages/apostrophe-astro/helpers/server/static.js delete mode 100644 packages/apostrophe-astro/helpers/slug.js delete mode 100644 packages/apostrophe-astro/helpers/url.js diff --git a/packages/apostrophe-astro/helpers/fetch.js b/packages/apostrophe-astro/helpers/fetch.js deleted file mode 100644 index b55944b2a5..0000000000 --- a/packages/apostrophe-astro/helpers/fetch.js +++ /dev/null @@ -1,52 +0,0 @@ -import config from 'apostrophe-astro-config/config'; -import { getAposHost } from './url.js'; - -/** - * A transparent proxy around the native `fetch` API for **server-side - * Astro code only** (`.astro` frontmatter, server endpoints, etc.). - * - * **Do NOT use in client-side code** — it depends on - * `apostrophe-astro-config/config` and exposes the internal backend host. - * For browser requests use plain `fetch` with relative URLs - * (e.g. `/api/v1/...`). - * - * What it does on top of native `fetch`: - * - Prepends the Apostrophe backend host (`getAposHost()`) to relative - * URLs (paths starting with `/`). - * - Injects the `x-apos-static-base-url: 1` header during static builds - * so the backend returns path-only URLs in its responses. - * - * Accepts the same arguments as `fetch(input, init?)` and returns a - * standard `Response`. All `init` options (method, body, headers, signal, - * etc.) are preserved and merged. - * - * @param {string|URL|Request} input - URL or Request object. Relative - * paths (starting with `/`) are resolved against `getAposHost()`. - * @param {RequestInit} [init] - Optional fetch init options. - * @returns {Promise} - * - * @example - * ```astro - * --- - * import { aposFetch } from '@apostrophecms/apostrophe-astro/helpers'; - * const response = await aposFetch('/api/v1/article?perPage=5'); - * const data = await response.json(); - * --- - * ``` - */ -export async function aposFetch(input, init) { - let url = input; - - if (typeof url === 'string' && url.startsWith('/')) { - url = getAposHost() + url; - } - const headers = new Headers(init?.headers); - if (config.staticBuild) { - headers.set('x-apos-static-base-url', '1'); - } - - return fetch(url, { - ...init || {}, - headers - }); -} diff --git a/packages/apostrophe-astro/helpers/index.js b/packages/apostrophe-astro/helpers/index.js deleted file mode 100644 index a953c3e71e..0000000000 --- a/packages/apostrophe-astro/helpers/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { getAposHost, isStaticBuild, buildPageUrl, getFilterBaseUrl, aposSetQueryParameter } from './url.js'; -export { slugify } from './slug.js'; -export { aposFetch } from './fetch.js'; diff --git a/packages/apostrophe-astro/helpers/server/index.js b/packages/apostrophe-astro/helpers/server/index.js index 03f6144683..d8b7481786 100644 --- a/packages/apostrophe-astro/helpers/server/index.js +++ b/packages/apostrophe-astro/helpers/server/index.js @@ -11,3 +11,4 @@ export { aposFetch, aposPageFetch } from './fetch.js'; export { getAposHost, isStaticBuild } from './url.js'; +export { getAllStaticPaths, getAllUrlMetadata, getLocales } from './static.js'; diff --git a/packages/apostrophe-astro/helpers/server/static.js b/packages/apostrophe-astro/helpers/server/static.js new file mode 100644 index 0000000000..543b64c74f --- /dev/null +++ b/packages/apostrophe-astro/helpers/server/static.js @@ -0,0 +1,9 @@ +/** + * Static build helpers — server-only. + * + * Re-exports the public static-build functions from `lib/static.js`. + * Use these in `getStaticPaths()` inside your `[...slug].astro` page + * to fetch all page paths and props from the Apostrophe backend. + */ + +export { getAllStaticPaths, getAllUrlMetadata, getLocales } from '../../lib/static.js'; diff --git a/packages/apostrophe-astro/helpers/slug.js b/packages/apostrophe-astro/helpers/slug.js deleted file mode 100644 index 1de99990db..0000000000 --- a/packages/apostrophe-astro/helpers/slug.js +++ /dev/null @@ -1,19 +0,0 @@ -import sluggo from "sluggo"; -import deburr from "lodash.deburr"; - -/** - * Apostrophe compatible slugify helper. - * - * @param {string} text - * @param {import('sluggo').Options} options - * @param {boolean} options.stripAccents - Whether to strip accents from characters. - * @returns - */ -export function slugify(text, options) { - const { stripAccents, ...opts } = options || {}; - const slug = sluggo(text, opts); - if (stripAccents) { - return deburr(slug); - } - return slug; -} diff --git a/packages/apostrophe-astro/helpers/url.js b/packages/apostrophe-astro/helpers/url.js deleted file mode 100644 index 0a5ad2e70d..0000000000 --- a/packages/apostrophe-astro/helpers/url.js +++ /dev/null @@ -1,179 +0,0 @@ -import config from 'apostrophe-astro-config/config'; - -/** - * Get the Apostrophe backend base URL, including the prefix when - * configured. - * - * Returns `config.aposHost + config.aposPrefix` — the full base URL - * for reaching the Apostrophe backend (e.g. - * `http://localhost:3000/my-repo`). Environment variable overrides - * (`APOS_HOST`, `APOS_PREFIX`) are resolved once at config time in - * the integration's `astro:config:setup` hook and stored in the - * virtual config module — this function does no env lookups. - * - * Prefer `aposFetch` for API calls — use `getAposHost()` only when - * you need the raw URL string (e.g. for building non-fetch URLs). - * - * WARNING: not to be confused with "Public Host" - this is meant to - * be used only in Astro server-side code. Use relative URLs for - * client-side requests `/api/v1/...`. - * - * @returns {string} The backend base URL (e.g. `http://localhost:3000` - * or `http://localhost:3000/my-repo`). - * - * @example - * ```astro - * --- - * import { getAposHost } from '@apostrophecms/apostrophe-astro/helpers'; - * const host = getAposHost(); - * // e.g. 'http://localhost:3000' or 'http://localhost:3000/my-repo' - * --- - * ``` - */ -export function getAposHost() { - return config.aposHost + (config.aposPrefix || ''); -} - -/** - * Check whether the current build is a static build. - * - * Returns `true` when the Astro integration is configured for - * static output (e.g. `output: 'static'` with `APOS_BUILD=static`). - * - * @returns {boolean} - * - * @example - * ```astro - * --- - * import { isStaticBuild } from '@apostrophecms/apostrophe-astro/helpers'; - * --- - * - * ``` - */ -export function isStaticBuild() { - return Boolean(config.staticBuild); -} - -// Mode-aware URL building utilities for Apostrophe piece index -// pages. -// -// Static URLs (`@apostrophecms/url` option `static: true`): -// /articles/page/2 -// /articles/categories/insights/page/2 -// -// Dynamic URLs (default): -// /articles?page=2 -// /articles?categories=insights&page=2 - -/** - * Get the effective base URL for the current filter context. - * - * If a filter choice is active, returns its `_url` (which already - * includes the filter segment in the correct format). Otherwise - * returns `page._url` — the plain index page URL. - * - * @param {object} aposData - The `aposData` object from `Astro.props`. - * @param {object} aposData.page - The page document (must have `_url`). - * @param {Array} [aposData.filters] - Filter definitions with choices. - * @returns {string} The base URL representing page 1 of the current - * filter context. - */ -export function getFilterBaseUrl(aposData) { - const { page, filters = [] } = aposData; - - for (const filter of filters) { - const activeChoice = filter.choices?.find((c) => c.active); - if (activeChoice?._url) { - return activeChoice._url; - } - } - - return page?._url || '/'; -} - -/** - * Build a pagination URL for a piece index page. - * - * Works in both static (path-based) and dynamic (query-string) modes. - * The mode is determined by `aposData.staticUrls`, which is set by the - * backend's `@apostrophecms/url` module when its `static` option is - * enabled. This ensures consistent URLs regardless of whether the - * Astro frontend runs in SSR or static build mode. - * - * The function determines the correct base URL by looking at the - * active filter choice (if any) and appends the page number in the - * appropriate format. - * - * Page 1 always returns the base URL without a page suffix. - * - * @param {object} aposData - The `aposData` object from `Astro.props`. - * Must contain `page` (with `_url`) and optionally `filters`. - * `aposData.staticUrls` controls path-based vs query-string URLs. - * @param {number} pageNum - The target page number (1-based). - * @returns {string} The URL for the given page. - * - * @example - * ```astro - * --- - * import { buildPageUrl } from '@apostrophecms/apostrophe-astro/helpers'; - * const { aposData } = Astro.props; - * --- - * Page 2 - * ``` - */ -export function buildPageUrl(aposData, pageNum) { - const baseUrl = getFilterBaseUrl(aposData); - - if (pageNum <= 1) { - return baseUrl; - } - - if (aposData.staticUrls) { - return `${baseUrl}/page/${pageNum}`; - } - - // Dynamic mode — append as query parameter, preserving any - // existing query string (e.g. ?categories=insights). - const url = new URL(baseUrl, 'http://localhost'); - url.searchParams.set('page', String(pageNum)); - return `${url.pathname}${url.search}`; -} - -/** - * Add, update or remove a named query parameter and return a new URL. - * This tool is not static URL aware. - * - * If `value` is `undefined`, `null` or empty string the parameter is - * removed from the query string. Internal Apostrophe parameters - * (`aposRefresh`, `aposMode`, `aposEdit`) are always stripped. - * - * Typically `Astro.url` is passed as the first argument. - * - * @param {URL|string} url - The current URL. - * @param {string} name - The query parameter name. - * @param {string|null|undefined} value - The value to set, or - * `null`/`undefined`/`''` to remove. - * @returns {URL} A new URL with the parameter applied. - * - * @example - * ```astro - * --- - * import { aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers'; - * const next = aposSetQueryParameter(Astro.url, 'page', '2'); - * --- - * Page 2 - * ``` - */ -export function aposSetQueryParameter(url, name, value) { - const newUrl = new URL(url); - // Internal query parameters not suitable for public facing URLs - newUrl.searchParams.delete('aposRefresh'); - newUrl.searchParams.delete('aposMode'); - newUrl.searchParams.delete('aposEdit'); - if ((value == null) || (value === '')) { - newUrl.searchParams.delete(name); - } else { - newUrl.searchParams.set(name, value); - } - return newUrl; -} From a33b84a99aa4f9984e9dac4850cf52bc9c720d14 Mon Sep 17 00:00:00 2001 From: Bob Means Date: Thu, 4 Jun 2026 05:41:25 -0400 Subject: [PATCH 04/39] Path correction --- packages/apostrophe-astro/MIGRATION.md | 13 ++++++++++++- packages/apostrophe-astro/index.js | 10 ++++++++-- packages/apostrophe-astro/package.json | 7 ++++--- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/apostrophe-astro/MIGRATION.md b/packages/apostrophe-astro/MIGRATION.md index 17f450c7c9..98110a9c89 100644 --- a/packages/apostrophe-astro/MIGRATION.md +++ b/packages/apostrophe-astro/MIGRATION.md @@ -55,7 +55,17 @@ import { slugify } from '@apostrophecms/apostrophe-astro/lib/util.js'; import { slugify } from '@apostrophecms/apostrophe-astro/helpers/universal'; ``` -### 4. Update `lib/aposStyles.js` and `lib/attachment.js` imports +### 4. Update `lib/static.js` imports + +```js +// Before +import { getAllStaticPaths } from '@apostrophecms/apostrophe-astro/lib/static.js'; + +// After +import { getAllStaticPaths } from '@apostrophecms/apostrophe-astro/helpers/server'; +``` + +### 5. Update `lib/aposStyles.js` and `lib/attachment.js` imports These files are no longer part of the public API. @@ -77,6 +87,7 @@ The following `lib/` paths remain exported in v2 as compatibility shims. They wi | Old path | New path | | --- | --- | | `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js` | `@apostrophecms/apostrophe-astro/helpers/server` | +| `@apostrophecms/apostrophe-astro/lib/static.js` | `@apostrophecms/apostrophe-astro/helpers/server` | | `@apostrophecms/apostrophe-astro/lib/util` | `@apostrophecms/apostrophe-astro/helpers/universal` | | `@apostrophecms/apostrophe-astro/lib/util.js` | `@apostrophecms/apostrophe-astro/helpers/universal` | | `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter` | `@apostrophecms/apostrophe-astro/helpers/universal` | diff --git a/packages/apostrophe-astro/index.js b/packages/apostrophe-astro/index.js index b66f0772fa..1db28af839 100644 --- a/packages/apostrophe-astro/index.js +++ b/packages/apostrophe-astro/index.js @@ -1,5 +1,6 @@ import { vitePluginApostropheGeneratedConfig } from './vite/vite-plugin-apostrophe-generated-config.js'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { writeConfigCache, writeLiteralContent, @@ -155,8 +156,13 @@ export default function apostropheIntegration(options) { staticBuild: isStaticBuild ? staticBuild : null }; + // config.root is a URL in Astro 5+ — convert to a file-system path. + const projectRoot = config.root instanceof URL + ? fileURLToPath(config.root) + : config.root; + const generatedDir = path.join( - config.root, + projectRoot, 'node_modules', '.apostrophe-astro-config' ); @@ -167,7 +173,7 @@ export default function apostropheIntegration(options) { vitePluginApostropheGeneratedConfig( options, integrationConfig, - config.root + projectRoot ) ], resolve: { diff --git a/packages/apostrophe-astro/package.json b/packages/apostrophe-astro/package.json index 4afb6a9d0e..6e8b32aaef 100644 --- a/packages/apostrophe-astro/package.json +++ b/packages/apostrophe-astro/package.json @@ -16,12 +16,13 @@ "./helpers/server": "./helpers/server/index.js", "./helpers/universal": "./helpers/universal/index.js", "./helpers/client": "./helpers/client/index.js", - "./components/*": "./components/*.astro", - "./components/layouts/*": "./components/layouts/*.astro", - "./widgets/*": "./widgets/*.astro", + "./components/*.astro": "./components/*.astro", + "./components/layouts/*.astro": "./components/layouts/*.astro", + "./widgets/*.astro": "./widgets/*.astro", "./endpoints/aposProxy.js": "./endpoints/aposProxy.js", "./endpoints/renderWidget.astro": "./endpoints/renderWidget.astro", "./lib/aposPageFetch.js": "./lib/aposPageFetch.js", + "./lib/static.js": "./lib/static.js", "./lib/util": "./lib/util.js", "./lib/util.js": "./lib/util.js", "./lib/aposSetQueryParameter": "./lib/aposSetQueryParameter.js", From a70b1701b05bcbe65e8bf36cfdbee3d79882c4bb Mon Sep 17 00:00:00 2001 From: Bob Means Date: Thu, 4 Jun 2026 05:50:38 -0400 Subject: [PATCH 05/39] Add .d.ts and tsconfig files --- packages/apostrophe-astro/package.json | 26 ++- packages/apostrophe-astro/tsconfig.json | 26 +++ .../types/helpers/client/index.d.ts | 0 .../types/helpers/server/fetch.d.ts | 59 ++++++ .../types/helpers/server/index.d.ts | 3 + .../types/helpers/server/static.d.ts | 1 + .../types/helpers/server/url.d.ts | 48 +++++ .../types/helpers/universal/attachment.d.ts | 110 +++++++++++ .../types/helpers/universal/index.d.ts | 4 + .../types/helpers/universal/slug.d.ts | 24 +++ .../types/helpers/universal/styles.d.ts | 53 ++++++ .../types/helpers/universal/url.d.ts | 74 ++++++++ packages/apostrophe-astro/types/index.d.ts | 163 ++++++++++++++++ .../types/lib/aposPageFetch.d.ts | 1 + .../types/lib/aposRequest.d.ts | 1 + .../types/lib/aposResponse.d.ts | 1 + .../types/lib/aposSetQueryParameter.d.ts | 2 + .../types/lib/aposStyles.d.ts | 2 + .../types/lib/attachment.d.ts | 47 +++++ .../apostrophe-astro/types/lib/format.d.ts | 9 + .../types/lib/getAreaForApi.d.ts | 1 + .../apostrophe-astro/types/lib/static.d.ts | 177 ++++++++++++++++++ packages/apostrophe-astro/types/lib/util.d.ts | 1 + ...te-plugin-apostrophe-generated-config.d.ts | 27 +++ 24 files changed, 856 insertions(+), 4 deletions(-) create mode 100644 packages/apostrophe-astro/tsconfig.json create mode 100644 packages/apostrophe-astro/types/helpers/client/index.d.ts create mode 100644 packages/apostrophe-astro/types/helpers/server/fetch.d.ts create mode 100644 packages/apostrophe-astro/types/helpers/server/index.d.ts create mode 100644 packages/apostrophe-astro/types/helpers/server/static.d.ts create mode 100644 packages/apostrophe-astro/types/helpers/server/url.d.ts create mode 100644 packages/apostrophe-astro/types/helpers/universal/attachment.d.ts create mode 100644 packages/apostrophe-astro/types/helpers/universal/index.d.ts create mode 100644 packages/apostrophe-astro/types/helpers/universal/slug.d.ts create mode 100644 packages/apostrophe-astro/types/helpers/universal/styles.d.ts create mode 100644 packages/apostrophe-astro/types/helpers/universal/url.d.ts create mode 100644 packages/apostrophe-astro/types/index.d.ts create mode 100644 packages/apostrophe-astro/types/lib/aposPageFetch.d.ts create mode 100644 packages/apostrophe-astro/types/lib/aposRequest.d.ts create mode 100644 packages/apostrophe-astro/types/lib/aposResponse.d.ts create mode 100644 packages/apostrophe-astro/types/lib/aposSetQueryParameter.d.ts create mode 100644 packages/apostrophe-astro/types/lib/aposStyles.d.ts create mode 100644 packages/apostrophe-astro/types/lib/attachment.d.ts create mode 100644 packages/apostrophe-astro/types/lib/format.d.ts create mode 100644 packages/apostrophe-astro/types/lib/getAreaForApi.d.ts create mode 100644 packages/apostrophe-astro/types/lib/static.d.ts create mode 100644 packages/apostrophe-astro/types/lib/util.d.ts create mode 100644 packages/apostrophe-astro/types/vite/vite-plugin-apostrophe-generated-config.d.ts diff --git a/packages/apostrophe-astro/package.json b/packages/apostrophe-astro/package.json index 6e8b32aaef..b82899e4fa 100644 --- a/packages/apostrophe-astro/package.json +++ b/packages/apostrophe-astro/package.json @@ -11,11 +11,26 @@ "homepage": "https://github.com/apostrophecms/apostrophe/tree/main/packages/apostrophe-astro#readme", "main": "index.js", "types": "types/index.d.ts", + "scripts": { + "build:types": "tsc" + }, "exports": { - ".": "./index.js", - "./helpers/server": "./helpers/server/index.js", - "./helpers/universal": "./helpers/universal/index.js", - "./helpers/client": "./helpers/client/index.js", + ".": { + "types": "./types/index.d.ts", + "default": "./index.js" + }, + "./helpers/server": { + "types": "./types/helpers/server/index.d.ts", + "default": "./helpers/server/index.js" + }, + "./helpers/universal": { + "types": "./types/helpers/universal/index.d.ts", + "default": "./helpers/universal/index.js" + }, + "./helpers/client": { + "types": "./types/helpers/client/index.d.ts", + "default": "./helpers/client/index.js" + }, "./components/*.astro": "./components/*.astro", "./components/layouts/*.astro": "./components/layouts/*.astro", "./widgets/*.astro": "./widgets/*.astro", @@ -34,5 +49,8 @@ "lodash.deburr": "^4.1.0", "sluggo": "^1.0.0", "undici": "^6.24.0" + }, + "devDependencies": { + "typescript": "^6.0.3" } } diff --git a/packages/apostrophe-astro/tsconfig.json b/packages/apostrophe-astro/tsconfig.json new file mode 100644 index 0000000000..2853346323 --- /dev/null +++ b/packages/apostrophe-astro/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "allowJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "checkJs": false, + "outDir": "./types", + "rootDir": ".", + "module": "ESNext", + "target": "ES2020", + "moduleResolution": "bundler", + "noEmitOnError": false, + "skipLibCheck": true, + "strict": false + }, + "include": [ + "index.js", + "helpers/**/*.js", + "lib/**/*.js" + ], + "exclude": [ + "node_modules", + "types", + "vite" + ] +} diff --git a/packages/apostrophe-astro/types/helpers/client/index.d.ts b/packages/apostrophe-astro/types/helpers/client/index.d.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/apostrophe-astro/types/helpers/server/fetch.d.ts b/packages/apostrophe-astro/types/helpers/server/fetch.d.ts new file mode 100644 index 0000000000..fd9dbc8f91 --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/server/fetch.d.ts @@ -0,0 +1,59 @@ +/** + * A transparent proxy around the native `fetch` API for **server-side + * Astro code only** (`.astro` frontmatter, server endpoints, etc.). + * + * **Do NOT use in client-side code** — it depends on + * `apostrophe-astro-config/config` and exposes the internal backend host. + * For browser requests use plain `fetch` with relative URLs + * (e.g. `/api/v1/...`). + * + * What it does on top of native `fetch`: + * - Prepends the Apostrophe backend host (`getAposHost()`) to relative + * URLs (paths starting with `/`). + * - Injects the `x-apos-static-base-url: 1` header during static builds + * so the backend returns path-only URLs in its responses. + * + * Accepts the same arguments as `fetch(input, init?)` and returns a + * standard `Response`. All `init` options (method, body, headers, signal, + * etc.) are preserved and merged. + * + * @param {string|URL|Request} input - URL or Request object. Relative + * paths (starting with `/`) are resolved against `getAposHost()`. + * @param {RequestInit} [init] - Optional fetch init options. + * @returns {Promise} + * + * @example + * ```astro + * --- + * import { aposFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; + * const response = await aposFetch('/api/v1/article?perPage=5'); + * const data = await response.json(); + * --- + * ``` + */ +export function aposFetch(input: string | URL | Request, init?: RequestInit): Promise; +/** + * Fetch a full Apostrophe page data object for the given Astro request. + * + * This is the primary entry point for SSR and static-build page routes. + * It wraps `aposRequest` and `aposResponse` to forward the incoming + * request to the Apostrophe backend and return the parsed JSON page data, + * including automatic handling of trailing-slash redirects. + * + * For static builds, use this inside `getStaticPaths` / your page + * frontmatter to retrieve the `aposData` prop. + * + * @param {Request} req - The incoming Astro request (`Astro.request`). + * @returns {Promise} The Apostrophe page data object. On error, + * returns an object with `errorFetchingPage` set to the caught error + * and `page.type` set to `'apos-fetch-error'`. + * + * @example + * ```astro + * --- + * import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; + * const aposData = await aposPageFetch(Astro.request); + * --- + * ``` + */ +export function aposPageFetch(req: Request): Promise; diff --git a/packages/apostrophe-astro/types/helpers/server/index.d.ts b/packages/apostrophe-astro/types/helpers/server/index.d.ts new file mode 100644 index 0000000000..a36ca8c7fe --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/server/index.d.ts @@ -0,0 +1,3 @@ +export { aposFetch, aposPageFetch } from "./fetch.js"; +export { getAposHost, isStaticBuild } from "./url.js"; +export { getAllStaticPaths, getAllUrlMetadata, getLocales } from "./static.js"; diff --git a/packages/apostrophe-astro/types/helpers/server/static.d.ts b/packages/apostrophe-astro/types/helpers/server/static.d.ts new file mode 100644 index 0000000000..f97ce4bc8e --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/server/static.d.ts @@ -0,0 +1 @@ +export { getAllStaticPaths, getAllUrlMetadata, getLocales } from "../../lib/static.js"; diff --git a/packages/apostrophe-astro/types/helpers/server/url.d.ts b/packages/apostrophe-astro/types/helpers/server/url.d.ts new file mode 100644 index 0000000000..ee85568185 --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/server/url.d.ts @@ -0,0 +1,48 @@ +/** + * Get the Apostrophe backend base URL, including the prefix when + * configured. + * + * Returns `config.aposHost + config.aposPrefix` — the full base URL + * for reaching the Apostrophe backend (e.g. + * `http://localhost:3000/my-repo`). Environment variable overrides + * (`APOS_HOST`, `APOS_PREFIX`) are resolved once at config time in + * the integration's `astro:config:setup` hook and stored in the + * generated config module — this function does no env lookups. + * + * Prefer `aposFetch` for API calls — use `getAposHost()` only when + * you need the raw URL string (e.g. for building non-fetch URLs). + * + * WARNING: not to be confused with "Public Host" — this is meant to + * be used only in Astro server-side code. Use relative URLs for + * client-side requests `/api/v1/...`. + * + * @returns {string} The backend base URL (e.g. `http://localhost:3000` + * or `http://localhost:3000/my-repo`). + * + * @example + * ```astro + * --- + * import { getAposHost } from '@apostrophecms/apostrophe-astro/helpers/server'; + * const host = getAposHost(); + * // e.g. 'http://localhost:3000' or 'http://localhost:3000/my-repo' + * --- + * ``` + */ +export function getAposHost(): string; +/** + * Check whether the current build is a static build. + * + * Returns `true` when the Astro integration is configured for + * static output (e.g. `output: 'static'`). + * + * @returns {boolean} + * + * @example + * ```astro + * --- + * import { isStaticBuild } from '@apostrophecms/apostrophe-astro/helpers/server'; + * --- + * + * ``` + */ +export function isStaticBuild(): boolean; diff --git a/packages/apostrophe-astro/types/helpers/universal/attachment.d.ts b/packages/apostrophe-astro/types/helpers/universal/attachment.d.ts new file mode 100644 index 0000000000..e15991b7e1 --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/universal/attachment.d.ts @@ -0,0 +1,110 @@ +/** + * Get focal point coordinates from an attachment or image object. + * + * Returns a CSS `object-position`-compatible string such as `"50% 75%"`. + * Falls back to `defaultValue` when no valid focal point is found. + * + * @param {object} attachmentObject - Either a full image object or direct attachment. + * @param {string} [defaultValue='center center'] - Value to return when no + * focal point is set. + * @returns {string} Focal point string for use in CSS (e.g. `"50% 50%"`). + * + * @example + * ```astro + * --- + * import { getFocalPoint } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * --- + * + * ``` + */ +export function getFocalPoint(attachmentObject: object, defaultValue?: string): string; +/** + * Get the width from an image object. + * + * Uses crop dimensions from `_fields` when available, otherwise falls + * back to the original attachment dimensions. + * + * @param {object} imageObject - Image object from ApostropheCMS. + * @returns {number|undefined} The width in pixels, or `undefined` if unavailable. + * + * @example + * ```astro + * --- + * import { getWidth } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * --- + * + * ``` + */ +export function getWidth(imageObject: object): number | undefined; +/** + * Get the height from an image object. + * + * Uses crop dimensions from `_fields` when available, otherwise falls + * back to the original attachment dimensions. + * + * @param {object} imageObject - Image object from ApostropheCMS. + * @returns {number|undefined} The height in pixels, or `undefined` if unavailable. + * + * @example + * ```astro + * --- + * import { getHeight } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * --- + * + * ``` + */ +export function getHeight(imageObject: object): number | undefined; +/** + * Get the URL for an attachment at an optional size variant. + * + * Handles the full-image object and direct attachment forms, crop + * parameters from `_fields`, and the "just-edited" state where the + * backend provides uncropped URLs. + * + * @param {object} imageObject - The full image object from ApostropheCMS. + * @param {object} [options={}] - Options. + * @param {string} [options.size='two-thirds'] - Size variant. One of: + * `'one-sixth'`, `'one-third'`, `'one-half'`, `'two-thirds'`, + * `'full'`, `'max'`, `'original'`. + * @param {string} [options.missingIcon] - Custom URL for missing attachments. + * @returns {string} The URL for the attachment. + * + * @example + * ```astro + * --- + * import { getAttachmentUrl } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * --- + * + * ``` + */ +export function getAttachmentUrl(imageObject: object, options?: { + size?: string; + missingIcon?: string; +}): string; +/** + * Generate a `srcset` string for an image attachment. + * + * Returns an empty string when the attachment has no multiple size + * variants (e.g. SVG or PDF files). + * + * @param {object} attachmentObject - Either a full image object or direct attachment. + * @param {object} [options={}] - Options. + * @param {Array<{ name: string, width: number, height?: number }>} [options.sizes] + * Custom size descriptors. Defaults to the standard ApostropheCMS sizes. + * @returns {string} A `srcset` attribute value, or `''` if not applicable. + * + * @example + * ```astro + * --- + * import { getAttachmentSrcset } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * --- + * + * ``` + */ +export function getAttachmentSrcset(attachmentObject: object, options?: { + sizes?: Array<{ + name: string; + width: number; + height?: number; + }>; +}): string; diff --git a/packages/apostrophe-astro/types/helpers/universal/index.d.ts b/packages/apostrophe-astro/types/helpers/universal/index.d.ts new file mode 100644 index 0000000000..e1079c9b17 --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/universal/index.d.ts @@ -0,0 +1,4 @@ +export { slugify } from "./slug.js"; +export { buildPageUrl, getFilterBaseUrl, aposSetQueryParameter } from "./url.js"; +export { stylesElements, stylesAttributes } from "./styles.js"; +export { getFocalPoint, getAttachmentUrl, getAttachmentSrcset, getWidth, getHeight } from "./attachment.js"; diff --git a/packages/apostrophe-astro/types/helpers/universal/slug.d.ts b/packages/apostrophe-astro/types/helpers/universal/slug.d.ts new file mode 100644 index 0000000000..fb5b9474bb --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/universal/slug.d.ts @@ -0,0 +1,24 @@ +/** + * Apostrophe-compatible slugify helper. + * + * Converts a string to a URL-safe slug using the same algorithm as + * the Apostrophe CMS backend, ensuring consistent slugs across + * frontend and backend. + * + * @param {string} text - The string to slugify. + * @param {import('sluggo').Options & { stripAccents?: boolean }} [options] - Options. + * @param {boolean} [options.stripAccents] - When `true`, strip accents + * from characters (e.g. `é` → `e`). All other options are forwarded + * to the underlying `sluggo` library. + * @returns {string} The slugified string. + * + * @example + * ```js + * import { slugify } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * slugify('Hello World'); // 'hello-world' + * slugify('Ça va?', { stripAccents: true }); // 'ca-va' + * ``` + */ +export function slugify(text: string, options?: any & { + stripAccents?: boolean; +}): string; diff --git a/packages/apostrophe-astro/types/helpers/universal/styles.d.ts b/packages/apostrophe-astro/types/helpers/universal/styles.d.ts new file mode 100644 index 0000000000..f916f7178f --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/universal/styles.d.ts @@ -0,0 +1,53 @@ +/** + * Widget styles helpers. + * + * Pure utilities for reading and merging ApostropheCMS style option + * data (set via `@apostrophecms/styles`) from widget objects. These + * helpers have no environment dependencies and work in both server + * and client contexts. + */ +/** + * Return the styles elements HTML string for a widget, or `null` if + * none are configured. + * + * The returned value is safe to pass to Astro's `set:html` directive. + * + * @param {object} widget - The widget object from ApostropheCMS. + * @returns {string|null} + * + * @example + * ```astro + * --- + * import { stylesElements } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * --- + * + * ``` + */ +export function stylesElements(widget: object): string | null; +/** + * Return a merged HTML attributes object combining the widget's + * ApostropheCMS styles attributes with any caller-supplied overrides. + * + * Classes are merged and deduplicated. Styles are concatenated. + * Any other attributes in `additionalAttrs` are merged in directly, + * with `undefined`/`null` values omitted. + * + * @param {object} widget - The widget object from ApostropheCMS. + * @param {object} [additionalAttrs={}] - Extra attributes to merge. + * @param {string|string[]} [additionalAttrs.class] - Additional CSS classes. + * @param {string} [additionalAttrs.style] - Additional inline style string. + * @returns {object} Merged attributes object suitable for Astro spread (`{...attrs}`). + * + * @example + * ```astro + * --- + * import { stylesAttributes } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * const attrs = stylesAttributes(widget, { class: 'my-extra-class' }); + * --- + *
+ * ``` + */ +export function stylesAttributes(widget: object, additionalAttrs?: { + class?: string | string[]; + style?: string; +}): object; diff --git a/packages/apostrophe-astro/types/helpers/universal/url.d.ts b/packages/apostrophe-astro/types/helpers/universal/url.d.ts new file mode 100644 index 0000000000..eaa68cc96b --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/universal/url.d.ts @@ -0,0 +1,74 @@ +/** + * Get the effective base URL for the current filter context. + * + * If a filter choice is active, returns its `_url` (which already + * includes the filter segment in the correct format). Otherwise + * returns `page._url` — the plain index page URL. + * + * @param {object} aposData - The `aposData` object from `Astro.props`. + * @param {object} aposData.page - The page document (must have `_url`). + * @param {Array} [aposData.filters] - Filter definitions with choices. + * @returns {string} The base URL representing page 1 of the current + * filter context. + */ +export function getFilterBaseUrl(aposData: { + page: object; + filters?: any[]; +}): string; +/** + * Build a pagination URL for a piece index page. + * + * Works in both static (path-based) and dynamic (query-string) modes. + * The mode is determined by `aposData.staticUrls`, which is set by the + * backend's `@apostrophecms/url` module when its `static` option is + * enabled. This ensures consistent URLs regardless of whether the + * Astro frontend runs in SSR or static build mode. + * + * The function determines the correct base URL by looking at the + * active filter choice (if any) and appends the page number in the + * appropriate format. + * + * Page 1 always returns the base URL without a page suffix. + * + * @param {object} aposData - The `aposData` object from `Astro.props`. + * Must contain `page` (with `_url`) and optionally `filters`. + * `aposData.staticUrls` controls path-based vs query-string URLs. + * @param {number} pageNum - The target page number (1-based). + * @returns {string} The URL for the given page. + * + * @example + * ```astro + * --- + * import { buildPageUrl } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * const { aposData } = Astro.props; + * --- + * Page 2 + * ``` + */ +export function buildPageUrl(aposData: object, pageNum: number): string; +/** + * Add, update or remove a named query parameter and return a new URL. + * This tool is not static URL aware. + * + * If `value` is `undefined`, `null` or empty string the parameter is + * removed from the query string. Internal Apostrophe parameters + * (`aposRefresh`, `aposMode`, `aposEdit`) are always stripped. + * + * Typically `Astro.url` is passed as the first argument. + * + * @param {URL|string} url - The current URL. + * @param {string} name - The query parameter name. + * @param {string|null|undefined} value - The value to set, or + * `null`/`undefined`/`''` to remove. + * @returns {URL} A new URL with the parameter applied. + * + * @example + * ```astro + * --- + * import { aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * const next = aposSetQueryParameter(Astro.url, 'page', '2'); + * --- + * Page 2 + * ``` + */ +export function aposSetQueryParameter(url: URL | string, name: string, value: string | null | undefined): URL; diff --git a/packages/apostrophe-astro/types/index.d.ts b/packages/apostrophe-astro/types/index.d.ts new file mode 100644 index 0000000000..d14aabbb14 --- /dev/null +++ b/packages/apostrophe-astro/types/index.d.ts @@ -0,0 +1,163 @@ +/** + * @typedef {object} StaticBuildOptions + * @property {boolean} [attachments=true] - Whether to copy attachments + * into the static build output. Overridden when `APOS_SKIP_ATTACHMENTS` + * env var is present (`1` disables, `0` enables). + * @property {string[]} [attachmentSizes] - Explicit image sizes to + * include (e.g. `['max', 'full']`). Overridden when the + * `APOS_ATTACHMENT_SIZES` env var is present (comma-separated, + * e.g. `max,full`). + * @property {string[]} [attachmentSkipSizes=['original']] - Image sizes + * to exclude. Overridden when `APOS_ATTACHMENT_SKIP_SIZES` env var + * is present (comma-separated, e.g. `original,max`). + * @property {'all'|'prettyOnly'} [attachmentFilter='all'] - Controls + * which attachment types to include in the build output. + * `'all'` (default) writes both regular uploadfs attachments and + * pretty URL files. `'prettyOnly'` skips regular uploadfs + * attachments (useful when those are served by a CDN) but still + * writes pretty URL files which are always backend-served. + * This option has no effect when `attachments` is `false`. + * Overridden when `APOS_ATTACHMENT_FILTER` env var is present. + * @property {'used'|'all'} [attachmentScope='used'] - `'used'` limits + * to attachments referenced by built pages; `'all'` includes every + * attachment in the database. Overridden when `APOS_ATTACHMENT_SCOPE` + * env var is present. + */ +/** + * @typedef {object} ApostropheIntegrationOptions + * @property {string} aposHost - The Apostrophe backend URL + * (e.g. `http://localhost:3000`). Can also be set via the + * `APOS_HOST` environment variable. + * @property {string} widgetsMapping - Import path to the widgets + * mapping module (e.g. `'./src/widgets/index.js'`). + * @property {string} templatesMapping - Import path to the templates + * mapping module (e.g. `'./src/templates/index.js'`). + * @property {((widget: object) => object|void)} [onBeforeWidgetRender] - + * Optional callback invoked before each widget is rendered. + * @property {string[]} [forwardHeaders] - Response headers to forward + * from the Apostrophe backend to the client. Deprecated in favour + * of `includeResponseHeaders`. + * @property {boolean} [viewTransitionWorkaround] - Enable the Astro + * view-transition workaround. + * @property {string[]} [includeResponseHeaders] - Response headers to + * include when proxying Apostrophe responses. Takes precedence + * over `forwardHeaders`. + * @property {string[]} [excludeRequestHeaders] - Request headers to + * strip before forwarding to the Apostrophe backend. + * @property {string[]} [proxyRoutes] - Additional route patterns to + * proxy to the Apostrophe backend in SSR mode. + * @property {string} [aposPrefix] - URL path prefix matching the + * Apostrophe backend `prefix` option (e.g. `'/my-repo'`). + * Auto-inferred from Astro's `base` config when omitted. + * Can also be set via the `APOS_PREFIX` environment variable. + * @property {StaticBuildOptions} [staticBuild] - Options controlling + * static build behaviour (attachments, sizes, scope). + */ +/** + * Apostrophe integration for Astro. + * + * @param {ApostropheIntegrationOptions} options + * @returns {import('astro').AstroIntegration} + */ +export default function apostropheIntegration(options: ApostropheIntegrationOptions): any; +export type StaticBuildOptions = { + /** + * - Whether to copy attachments + * into the static build output. Overridden when `APOS_SKIP_ATTACHMENTS` + * env var is present (`1` disables, `0` enables). + */ + attachments?: boolean; + /** + * - Explicit image sizes to + * include (e.g. `['max', 'full']`). Overridden when the + * `APOS_ATTACHMENT_SIZES` env var is present (comma-separated, + * e.g. `max,full`). + */ + attachmentSizes?: string[]; + /** + * - Image sizes + * to exclude. Overridden when `APOS_ATTACHMENT_SKIP_SIZES` env var + * is present (comma-separated, e.g. `original,max`). + */ + attachmentSkipSizes?: string[]; + /** + * - Controls + * which attachment types to include in the build output. + * `'all'` (default) writes both regular uploadfs attachments and + * pretty URL files. `'prettyOnly'` skips regular uploadfs + * attachments (useful when those are served by a CDN) but still + * writes pretty URL files which are always backend-served. + * This option has no effect when `attachments` is `false`. + * Overridden when `APOS_ATTACHMENT_FILTER` env var is present. + */ + attachmentFilter?: "all" | "prettyOnly"; + /** + * - `'used'` limits + * to attachments referenced by built pages; `'all'` includes every + * attachment in the database. Overridden when `APOS_ATTACHMENT_SCOPE` + * env var is present. + */ + attachmentScope?: "used" | "all"; +}; +export type ApostropheIntegrationOptions = { + /** + * - The Apostrophe backend URL + * (e.g. `http://localhost:3000`). Can also be set via the + * `APOS_HOST` environment variable. + */ + aposHost: string; + /** + * - Import path to the widgets + * mapping module (e.g. `'./src/widgets/index.js'`). + */ + widgetsMapping: string; + /** + * - Import path to the templates + * mapping module (e.g. `'./src/templates/index.js'`). + */ + templatesMapping: string; + /** + * - + * Optional callback invoked before each widget is rendered. + */ + onBeforeWidgetRender?: ((widget: object) => object | void); + /** + * - Response headers to forward + * from the Apostrophe backend to the client. Deprecated in favour + * of `includeResponseHeaders`. + */ + forwardHeaders?: string[]; + /** + * - Enable the Astro + * view-transition workaround. + */ + viewTransitionWorkaround?: boolean; + /** + * - Response headers to + * include when proxying Apostrophe responses. Takes precedence + * over `forwardHeaders`. + */ + includeResponseHeaders?: string[]; + /** + * - Request headers to + * strip before forwarding to the Apostrophe backend. + */ + excludeRequestHeaders?: string[]; + /** + * - Additional route patterns to + * proxy to the Apostrophe backend in SSR mode. + */ + proxyRoutes?: string[]; + /** + * - URL path prefix matching the + * Apostrophe backend `prefix` option (e.g. `'/my-repo'`). + * Auto-inferred from Astro's `base` config when omitted. + * Can also be set via the `APOS_PREFIX` environment variable. + */ + aposPrefix?: string; + /** + * - Options controlling + * static build behaviour (attachments, sizes, scope). + */ + staticBuild?: StaticBuildOptions; +}; diff --git a/packages/apostrophe-astro/types/lib/aposPageFetch.d.ts b/packages/apostrophe-astro/types/lib/aposPageFetch.d.ts new file mode 100644 index 0000000000..6a6d8a6dde --- /dev/null +++ b/packages/apostrophe-astro/types/lib/aposPageFetch.d.ts @@ -0,0 +1 @@ +export { aposPageFetch as default } from "../helpers/server/fetch.js"; diff --git a/packages/apostrophe-astro/types/lib/aposRequest.d.ts b/packages/apostrophe-astro/types/lib/aposRequest.d.ts new file mode 100644 index 0000000000..f3538af2bb --- /dev/null +++ b/packages/apostrophe-astro/types/lib/aposRequest.d.ts @@ -0,0 +1 @@ +export default function _default(req: any): Request; diff --git a/packages/apostrophe-astro/types/lib/aposResponse.d.ts b/packages/apostrophe-astro/types/lib/aposResponse.d.ts new file mode 100644 index 0000000000..07ea9a9355 --- /dev/null +++ b/packages/apostrophe-astro/types/lib/aposResponse.d.ts @@ -0,0 +1 @@ +export default function aposResponse(req: any): Promise; diff --git a/packages/apostrophe-astro/types/lib/aposSetQueryParameter.d.ts b/packages/apostrophe-astro/types/lib/aposSetQueryParameter.d.ts new file mode 100644 index 0000000000..8d017c0c8b --- /dev/null +++ b/packages/apostrophe-astro/types/lib/aposSetQueryParameter.d.ts @@ -0,0 +1,2 @@ +export default aposSetQueryParameter; +import { aposSetQueryParameter } from '../helpers/universal/url.js'; diff --git a/packages/apostrophe-astro/types/lib/aposStyles.d.ts b/packages/apostrophe-astro/types/lib/aposStyles.d.ts new file mode 100644 index 0000000000..dea2a86404 --- /dev/null +++ b/packages/apostrophe-astro/types/lib/aposStyles.d.ts @@ -0,0 +1,2 @@ +export function stylesElements(widget: any): any; +export function stylesAttributes(widget: any, additionalAttrs?: {}): any; diff --git a/packages/apostrophe-astro/types/lib/attachment.d.ts b/packages/apostrophe-astro/types/lib/attachment.d.ts new file mode 100644 index 0000000000..eca68c5e5b --- /dev/null +++ b/packages/apostrophe-astro/types/lib/attachment.d.ts @@ -0,0 +1,47 @@ +/** + * Get focal point coordinates from attachment or image, or return default value if invalid + * @param {Object} attachmentObject - Either a full image object or direct attachment + * @param {string} [defaultValue='center center'] - Default value to return if no valid focal point + * @returns {string} String with focal point for styling (e.g., "50% 50%") or default value if invalid + */ +export function getFocalPoint(attachmentObject: any, defaultValue?: string): string; +/** + * Get the width from the image object, using crop dimensions if available, + * otherwise falling back to original image dimensions + * @param {object} imageObject - Image object from ApostropheCMS + * @returns {number|undefined} The width of the image + */ +export function getWidth(imageObject: object): number | undefined; +/** + * Get the height from the image object, using crop dimensions if available, + * otherwise falling back to original image dimensions + * @param {object} imageObject - Image object from ApostropheCMS + * @returns {number|undefined} The height of the image + */ +export function getHeight(imageObject: object): number | undefined; +/** + * Get URL for an attachment with optional size + * @param {Object} imageObject - The full image object from ApostropheCMS + * @param {Object} [options={}] - Options object + * @param {string} [options.size] - Size variant ('one-sixth', 'one-third', + * 'one-half', 'two-thirds', 'full', 'max', 'original') + * @param {string} [options.missingIcon] - Custom URL for missing attachment (optional) + * @returns {string} The URL for the attachment + */ +export function getAttachmentUrl(imageObject: any, options?: { + size?: string; + missingIcon?: string; +}): string; +/** + * Generate a srcset for an image attachment + * @param {Object} attachmentObject - Either a full image object or direct attachment + * @param {Object} [options] - Options for generating the srcset + * @param {Array} [options.sizes] - Array of custom size objects to override the default sizes + * @param {string} options.sizes[].name - The name of the size (e.g., 'small', 'medium') + * @param {number} options.sizes[].width - The width of the image for this size + * @param {number} [options.sizes[].height] - The height of the image for this size (optional) + * @returns {string} The srcset string + */ +export function getAttachmentSrcset(attachmentObject: any, options?: { + sizes?: any[]; +}): string; diff --git a/packages/apostrophe-astro/types/lib/format.d.ts b/packages/apostrophe-astro/types/lib/format.d.ts new file mode 100644 index 0000000000..19b168750f --- /dev/null +++ b/packages/apostrophe-astro/types/lib/format.d.ts @@ -0,0 +1,9 @@ +export function getTimeStat(timeStart: any, timeEnd: any): string; +export function timestamp(): any; +export function bgGreen(s: any): any; +export function black(s: any): any; +export function blue(s: any): any; +export function dim(s: any): any; +export function green(s: any): any; +export function yellow(s: any): any; +export function red(s: any): any; diff --git a/packages/apostrophe-astro/types/lib/getAreaForApi.d.ts b/packages/apostrophe-astro/types/lib/getAreaForApi.d.ts new file mode 100644 index 0000000000..612aca08f8 --- /dev/null +++ b/packages/apostrophe-astro/types/lib/getAreaForApi.d.ts @@ -0,0 +1 @@ +export default function getDataForInlineRender(Astro: any): Promise; diff --git a/packages/apostrophe-astro/types/lib/static.d.ts b/packages/apostrophe-astro/types/lib/static.d.ts new file mode 100644 index 0000000000..2a0f698609 --- /dev/null +++ b/packages/apostrophe-astro/types/lib/static.d.ts @@ -0,0 +1,177 @@ +/** + * Persist static build configuration to the cache directory. + * Cleans previous cache before writing. Called from the + * integration's `astro:config:setup` hook. + * + * @param {object} staticBuild - Resolved static build config. + */ +export function writeConfigCache(staticBuild: object): Promise; +/** + * Fetch supported locales from the Apostrophe backend. + * + * @param {object} config + * @param {string} config.aposHost - The Apostrophe backend URL. + * @param {string} config.aposExternalFrontKey - The external front key. + * @returns {Promise>} + */ +export function getLocales({ aposHost, aposExternalFrontKey }: { + aposHost: string; + aposExternalFrontKey: string; +}): Promise>; +/** + * Fetch URL metadata for a single locale from the Apostrophe backend. + * + * Returns an object with four properties: + * - `paths`: entries for `getStaticPaths` (renderable HTML pages) + * - `literalContent`: entries with a `contentType` (CSS, robots.txt, etc.) + * that must be written to disk separately. + * - `attachments`: attachment metadata from the backend (when requested). + * Each entry has `_id` and `urls` (array of `{ size, path }`). + * Also includes `uploadsUrl` — the uploadfs base URL prefix. + * Entries for pretty-URL files additionally carry a `base` property + * that overrides `uploadsUrl` for those entries. + * + * Results are cached to the filesystem per locale so that the + * `astro:build:done` hook can read literal content entries without + * re-fetching. + * + * @param {object} config + * @param {string} config.aposHost - The Apostrophe backend URL + * (e.g. `http://localhost:3000`). + * @param {string} config.aposExternalFrontKey - The shared secret key + * used to authenticate with the Apostrophe external frontend API. + * @param {string} [config.locale] - The locale to fetch metadata for. + * When omitted, the backend returns metadata for the default locale. + * @param {object} [config.staticBuild] - Static build config from the + * integration (resolved from `apostrophe-astro-config/config`). + * @returns {Promise<{ paths: Array<{ params: { slug: string | undefined }, props: object }>, literalContent: Array, attachments: object | null }>} + */ +export function getAllUrlMetadata(config: { + aposHost: string; + aposExternalFrontKey: string; + locale?: string; + staticBuild?: object; +}): Promise<{ + paths: Array<{ + params: { + slug: string | undefined; + }; + props: object; + }>; + literalContent: Array; + attachments: object | null; +}>; +/** + * Fetch URL metadata for all supported locales and return a combined + * paths array suitable for Astro's `getStaticPaths`. + * + * This is the main entry point for static builds. It: + * 1. Fetches the list of supported locales from Apostrophe + * 2. Calls `getAllUrlMetadata` for each non-private locale + * 3. Caches literal content per locale for the post-build hook + * 4. Deduplicates attachment metadata across locales and caches it + * 5. Returns a flat array of `{ params, props }` entries + * + * Static build configuration is read from the cache written during + * `astro:config:setup`. Callers may override any value by passing + * it explicitly in `config`. + * + * @param {object} config + * @param {string} config.aposHost - The Apostrophe backend URL. + * @param {string} config.aposExternalFrontKey - The external front key. + * @returns {Promise>} + */ +export function getAllStaticPaths(config: { + aposHost: string; + aposExternalFrontKey: string; +}): Promise>; +/** + * Read cached literal content entries and write them to the build + * output directory. Called from the integration's `astro:build:done` + * hook. + * + * Literal content entries (CSS, robots.txt, etc.) have a `contentType` + * and cannot be generated as Astro pages (which always produce HTML). + * Duplicate URLs across locales are written only once. + * + * @param {object} options + * @param {string} options.aposHost - The Apostrophe backend URL. + * @param {string} options.aposExternalFrontKey - The external front key. + * @param {string} options.outDir - The absolute path to the build output directory. + * @param {import('astro').AstroIntegrationLogger} options.logger - Astro integration logger. + */ +export function writeLiteralContent({ aposHost, aposExternalFrontKey, outDir, logger, aposPrefix }: { + aposHost: string; + aposExternalFrontKey: string; + outDir: string; + logger: any; +}): Promise<{ + written: number; + warnings: number; + errors: number; +}>; +/** + * Read cached attachment metadata and download attachment files into + * the build output directory. Called from the integration's + * `astro:build:done` hook after `writeLiteralContent`. + * + * Attachments are not localized — the cache file contains a single + * deduplicated set of attachments across all locales, written by + * `getAllStaticPaths`. + * + * If `uploadsUrl` (from the backend's uploadfs configuration) is a + * relative path (e.g. `/uploads`), it is prefixed with `aposHost` for + * downloading. If it is an absolute URL (e.g. a CDN), it is used + * directly. + * + * Downloads are performed with controlled concurrency to avoid + * overwhelming the server. + * + * @param {object} options + * @param {string} options.aposHost - The Apostrophe backend URL. + * @param {string} options.outDir - The absolute path to the build output directory. + * @param {import('astro').AstroIntegrationLogger} options.logger - Astro integration logger. + */ +export function writeAttachments({ aposHost, outDir, logger, aposPrefix, attachmentFilter }: { + aposHost: string; + outDir: string; + logger: any; +}): Promise<{ + written: number; + warnings: number; + errors: number; +}>; +/** + * Write a combined post-build summary for literal content and + * attachment downloads. Uses warning-level output when only + * client errors (4xx) occurred, and error-level output when + * server errors (5xx) or exceptions were encountered. + * + * @param {object} options + * @param {{ written: number, warnings: number, errors: number }} options.literal - Stats from writeLiteralContent. + * @param {{ written: number, warnings: number, errors: number }} options.attachments - Stats from writeAttachments. + * @param {import('astro').AstroIntegrationLogger} options.logger - Astro integration logger. + */ +export function writePostBuildSummary({ literal, attachments, logger }: { + literal: { + written: number; + warnings: number; + errors: number; + }; + attachments: { + written: number; + warnings: number; + errors: number; + }; + logger: any; +}): void; +export function cleanupCache(): Promise; diff --git a/packages/apostrophe-astro/types/lib/util.d.ts b/packages/apostrophe-astro/types/lib/util.d.ts new file mode 100644 index 0000000000..5583be9cd7 --- /dev/null +++ b/packages/apostrophe-astro/types/lib/util.d.ts @@ -0,0 +1 @@ +export { slugify } from "../helpers/universal/slug.js"; diff --git a/packages/apostrophe-astro/types/vite/vite-plugin-apostrophe-generated-config.d.ts b/packages/apostrophe-astro/types/vite/vite-plugin-apostrophe-generated-config.d.ts new file mode 100644 index 0000000000..4de81728bb --- /dev/null +++ b/packages/apostrophe-astro/types/vite/vite-plugin-apostrophe-generated-config.d.ts @@ -0,0 +1,27 @@ +/** + * Vite plugin that generates real runtime files to replace the two + * old virtual-module plugins (`vite-plugin-apostrophe-config` and + * `vite-plugin-apostrophe-doctype`). + * + * Files are written to `node_modules/.apostrophe-astro-config/` and + * consumed through Vite aliases registered by the integration's + * `astro:config:setup` hook. + * + * Timing strategy + * ─────────────── + * • Dev mode – `configureServer` returns a post-middleware hook that + * runs after Vite's plugin container is fully initialised but before + * any request is served, giving us access to `server.pluginContainer` + * for resolution. + * • Build mode – `buildStart` has `this.resolve()` available and runs + * before any module transformation begins. + * + * A `filesWritten` flag prevents redundant disk writes if both hooks + * happen to fire in the same process. + * + * @param {import('../index.js').ApostropheIntegrationOptions} options + * @param {object} integrationConfig - Resolved integration config to serialise. + * @param {string} projectRoot - Astro project root (`config.root`). + * @returns {import('vite').Plugin} + */ +export function vitePluginApostropheGeneratedConfig(options: import("../index.js").ApostropheIntegrationOptions, integrationConfig: object, projectRoot: string): any; From 83272f4917d88f06d465191e053f8ed6cfd03697 Mon Sep 17 00:00:00 2001 From: Bob Means Date: Thu, 4 Jun 2026 06:07:04 -0400 Subject: [PATCH 06/39] Remove first design doc --- packages/apostrophe-astro/design.md | 487 ---------------------------- 1 file changed, 487 deletions(-) delete mode 100644 packages/apostrophe-astro/design.md diff --git a/packages/apostrophe-astro/design.md b/packages/apostrophe-astro/design.md deleted file mode 100644 index 25360e7df8..0000000000 --- a/packages/apostrophe-astro/design.md +++ /dev/null @@ -1,487 +0,0 @@ -## Background and Motivation - -The `@apostrophecms/apostrophe-astro` integration currently relies on two Vite virtual modules to surface runtime configuration and doctype mappings to Astro components and helper code: - -- `virtual:apostrophe-config`: exports resolved integration config, including host, prefix, header lists, and static build flags. -- `virtual:apostrophe-doctypes`: re-exports the user-supplied widgets and templates mapping modules, plus the optional `onBeforeWidgetRender` hook. - -These virtual modules are implemented with Vite plugin `resolveId` and `load` hooks using the `\0`-prefixed internal module ID convention. - -Vite virtual modules are a documented Vite plugin pattern, but this integration's current use is fragile in newer Astro/Vite versions because the generated modules are consumed across Astro's server, prerender, and client-oriented processing paths. Astro 6 uses Vite 7 and Vite's Environment API internally, which makes the old assumptions around one consistent module graph less reliable. - -The goal of v2 is to remove these runtime virtual-module dependencies and use the same breaking release to define the package's public import surface clearly. - ---- - -## v2 Scope - -This release makes two coordinated breaking changes: - -1. **Generated runtime files** replace the current Vite virtual modules. This is the Astro 6 compatibility fix. -2. **Public helper import paths** are formalized. Public helpers move behind stable helper entry points, while `lib/` becomes internal implementation. - -These changes ship together in v2. There is no phased rollout. Starter kits and README examples should be updated in the same release window so project code moves to the final public import paths immediately. - ---- - -## Problem: Why the Current Virtual Modules Break - -### Astro now processes code through multiple Vite environments - -Astro 6 runs on Vite 7 and uses Vite's Environment API internally. During dev and build, Astro code can be processed for different purposes: server rendering, prerendering, client output, and framework/runtime integration work. - -The current integration assumes that an import of `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` will always be resolved through the same plugin pipeline and with the same surrounding module graph. That assumption is now too weak. Helpers and internal library modules are server-only, while components and endpoints can be transformed in several Astro build contexts. - -### The doctype virtual module is the riskiest part - -`vite-plugin-apostrophe-doctype.js` currently calls `this.resolve()` inside its `load()` hook to resolve the user's `widgetsMapping`, `templatesMapping`, and optional `onBeforeWidgetRender` files. It then synthesizes a module that imports those resolved IDs. - -That creates a fragile chain: - -1. Resolve user mapping IDs during virtual module load. -2. Emit synthetic import statements from the virtual module. -3. Rely on those emitted imports resolving again in the environment that consumes the virtual module. - -This is sensitive to when and where `load()` runs. Failures show up as missing `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` modules, failed widget/template lookup, or helper code being unable to read integration config. - -### The `\0` prefix is not the root bug - -The `\0` prefix is still a normal Vite virtual-module convention. The issue is not that Vite has removed support for virtual modules. The issue is that this package is using virtual modules to carry generated integration state and resolved user imports across Astro/Vite environments where real files are a better fit. - ---- - -## Implementation Area 1: Generated Runtime Files - -### Core idea - -Replace the virtual module plugins with generated real files: - -1. During `astro:config:setup`, write generated runtime files under `node_modules/.apostrophe-astro-config`. This will suffice for both single site projects and multisite projects where a single Astro code instance is utilized. -2. Point internal imports at those files with Vite aliases. -3. Keep using Vite's resolver for user-supplied mapping paths so extensionless directories, project aliases, and package exports continue to work. - -Real files on disk are easier for Vite, Astro, and Node to reason about than synthetic modules returned from `load()`. They are also directly inspectable when debugging a project. - -### Generated file location - -Files are written to a dedicated hidden directory: - -```txt -node_modules/ - .apostrophe-astro-config/ - config.js - doctypes.js -``` - -The static build cache in `lib/static.js` is also refactored in this release from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/`. This is a private internal change with no user-facing impact. It makes the naming convention consistent across all three directories and removes any risk of the static setup wipe accidentally deleting generated runtime files. - -The three directories and their responsibilities: - -- `node_modules/.apostrophe-astro-static/`: temporary static build cache owned by `lib/static.js`. -- `node_modules/.apostrophe-astro-config/`: generated runtime modules owned by the integration setup hook. - -No `.gitignore` change is needed because both directories live under `node_modules/`. - -### Generated file content - -Each generated file includes a header: - -```js -// AUTO-GENERATED by @apostrophecms/apostrophe-astro. Do not edit. -// This file is regenerated on every dev server start and build. -``` - -`config.js` serializes the resolved integration config as a static ES module export: - -```js -export default { - aposHost: "http://localhost:3000", - aposPrefix: "", - includeResponseHeaders: ["set-cookie"], - excludeRequestHeaders: [], - viewTransitionWorkaround: false, - staticBuild: null -}; -``` - -This is the same object currently produced by the `load()` handler of `vite-plugin-apostrophe-config.js`. - -`doctypes.js` re-exports the user's mapping modules: - -```js -import { default as widgets } from "../../src/widgets/index.js"; -import { default as templates } from "../../src/templates/index.js"; -import onBeforeWidgetRenderHookFn from "../../src/hooks/onBeforeWidgetRender.js"; - -export { widgets, templates }; -export const onBeforeWidgetRenderHook = onBeforeWidgetRenderHookFn; -``` - -When no hook is provided, `onBeforeWidgetRenderHook` exports `undefined` as before. - -### Resolving user mapping files - -The rewrite must preserve the current behavior of `widgetsMapping`, `templatesMapping`, and `onBeforeWidgetRender`. - -The README documents values like: - -```js -widgetsMapping: './src/widgets', -templatesMapping: './src/templates', -onBeforeWidgetRender: './src/hooks/before-widget-render.js' -``` - -Those paths may be extensionless directories, explicit files, package subpaths, or project aliases. A simple `path.resolve(process.cwd(), mapping)` is not equivalent to the current behavior. - -Implementation should resolve these mappings with a small internal Vite plugin that runs before normal module transformation. This preserves Vite/Rollup resolution behavior while removing the runtime virtual modules. - -The integration's `astro:config:setup` hook should register a setup-only Vite plugin: - -```js -function vitePluginApostropheGeneratedConfig(options, resolvedConfig) { - return { - name: 'vite-plugin-apostrophe-generated-config', - enforce: 'pre', - async buildStart() { - const resolvedWidgets = await this.resolve(options.widgetsMapping); - const resolvedTemplates = await this.resolve(options.templatesMapping); - const resolvedHook = options.onBeforeWidgetRender - ? await this.resolve(options.onBeforeWidgetRender) - : null; - - await writeGeneratedRuntimeFiles({ - config: resolvedConfig, - widgetsId: resolvedWidgets?.id, - templatesId: resolvedTemplates?.id, - hookId: resolvedHook?.id - }); - } - }; -} -``` - -This plugin replaces the two existing virtual-module plugins. It does not return generated module source from `load()`. Its only job is to use Vite's resolver, then write real files under `node_modules/.apostrophe-astro-config/`. - -The generated files are then consumed through aliases registered in the same `astro:config:setup` call. All paths are computed from `config.root` — the project root Astro is operating on — rather than `process.cwd()`. This keeps paths correct regardless of where `astro` is invoked from, and is portable across project renames and moves since the paths are recomputed fresh on every dev server start and build: - -```js -const generatedDir = path.join(config.root, 'node_modules/.apostrophe-astro-config'); - -updateConfig({ - vite: { - plugins: [ - vitePluginApostropheGeneratedConfig(options, resolvedConfig) - ], - resolve: { - alias: { - 'apostrophe-astro-config/config': path.join(generatedDir, 'config.js'), - 'apostrophe-astro-config/doctypes': path.join(generatedDir, 'doctypes.js') - } - } - } -}); -``` - -The aliases point directly to the generated files. That keeps internal imports deterministic even though the files are generated during startup. - -If Vite starts resolving internal imports before `buildStart()` writes the files in dev mode, the fix is not a simple move to `configResolved()`. The `configResolved()` hook does not provide `this.resolve()`, so extensionless directory paths, project aliases, and package subpaths in `widgetsMapping` and `templatesMapping` would not resolve correctly there. The likely solution is a `configureServer` hook for dev mode or an explicit two-pass approach. The implementation must verify timing across dev startup, SSR rendering, and static build startup, because the hook execution order differs between those paths. - -If resolution fails, the integration should throw an error that names the specific option that failed, for example: - -```txt -Could not resolve apostrophe-astro widgetsMapping: ./src/widgets -``` - -### Import specifiers - -Internal imports are updated from virtual module specifiers to internal alias specifiers: - -| Old | New | -| --- | --- | -| `virtual:apostrophe-config` | `apostrophe-astro-config/config` | -| `virtual:apostrophe-doctypes` | `apostrophe-astro-config/doctypes` | - -These specifiers remain internal implementation details. They are not documented for project code. - -All internal files that currently import `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` are updated: - -`apostrophe-astro-config/config`: - -- `lib/aposRequest.js` -- `lib/aposResponse.js` -- `lib/aposPageFetch.js` -- `helpers/fetch.js` -- `helpers/url.js` -- `components/layouts/AposLayout.astro` -- `components/layouts/AposEditLayout.astro` - -`apostrophe-astro-config/doctypes`: - -- `components/AposWidget.astro` -- `components/AposTemplate.astro` -- `endpoints/renderWidget.astro` - -### Hidden virtual import audit - -The implementation must remove all internal imports of: - -- `virtual:apostrophe-config` -- `virtual:apostrophe-doctypes` - -Current known import sites are: - -- `helpers/fetch.js` -- `helpers/url.js` -- `lib/aposPageFetch.js` -- `lib/aposRequest.js` -- `lib/aposResponse.js` -- `components/layouts/AposLayout.astro` -- `components/layouts/AposEditLayout.astro` -- `components/AposWidget.astro` -- `components/AposTemplate.astro` -- `endpoints/renderWidget.astro` - -The implementation should also search for `virtual:` in the package before release to catch any hidden or newly introduced virtual imports. - -### Tradeoffs - -| | Virtual modules (current) | Generated runtime files (proposed) | -| --- | --- | --- | -| Astro 6 / Vite 7 environments | Fragile for this use case | Real files resolve consistently | -| Debuggability | Module content invisible on disk | Files inspectable in `node_modules/.apostrophe-astro-config/` | -| Static cache interaction | Not applicable | Kept separate; static cache refactored to `.apostrophe-astro-static/` | -| Complexity | Two virtual-module plugins with `resolveId`/`load` | File generation plus mapping resolution | -| Cold start | No disk I/O | Writes two small files at config time | - ---- - -## Implementation Area 2: Public Helper Architecture - -### Problem with the current state - -The integration currently has no `package.json` exports map. Consumers can import any file path that happens to exist in the package, including internal `lib/` files. The README also documents at least one `lib/` import, `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js`, so the current public surface is partly accidental and partly documented. - -This v2 release should be the last breaking change around project helper imports. The package needs a clear rule: - -- `helpers/` is public. -- `lib/` is internal. -- Helpers are separated into three explicit categories by import path: server-only, universal, and client-only. -- There is no top-level `helpers` barrel — consumers must choose the correct path deliberately. -- README examples and starter kits use only public helper paths. - -### Public helper entry points - -The package exports three helper entry points. There is no top-level `helpers` barrel — all imports must use an explicit path: - -- `@apostrophecms/apostrophe-astro/helpers/server`: server-only helpers for Astro frontmatter, routes, endpoints, prerendering, and SSR. These import from generated config, `process.env`, or Node.js built-ins unavailable in browsers. -- `@apostrophecms/apostrophe-astro/helpers/universal`: helpers that work in both server and client contexts. These are pure functions with no environment dependencies. -- `@apostrophecms/apostrophe-astro/helpers/client`: browser-only helpers that depend on browser APIs such as `window` or `document`. Currently reserved — no helpers are in this category yet — but the path and taxonomy are established now for future use. - -### Helper classification - -Server-only public helpers: - -| Export | Source | Reason | -| --- | --- | --- | -| `aposPageFetch` | `lib/aposPageFetch.js` | Fetches Apostrophe page data through server request/response helpers and generated config | -| `aposFetch` | `helpers/fetch.js` | Prepends backend host and reads generated config | -| `getAposHost` | `helpers/server-url.js` | Exposes backend host from generated config | -| `isStaticBuild` | `helpers/server-url.js` | Reads generated config | - -Universal public helpers: - -| Export | Source | Reason | -| --- | --- | --- | -| `buildPageUrl` | `helpers/universal/url.js` | Pure URL construction from provided Apostrophe data | -| `getFilterBaseUrl` | `helpers/universal/url.js` | Pure data inspection | -| `aposSetQueryParameter` | `helpers/universal/url.js` | Pure URL manipulation | -| `slugify` | `helpers/universal/slug.js` | Pure string utility | -| `stylesElements` | `helpers/universal/styles.js` | Pure widget data helper moved from `lib/aposStyles.js` | -| `stylesAttributes` | `helpers/universal/styles.js` | Pure widget data helper moved from `lib/aposStyles.js` | -| `getFocalPoint` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | -| `getAttachmentUrl` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | -| `getAttachmentSrcset` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | -| `getWidth` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | -| `getHeight` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | - -The `helpers/universal/index.js` barrel must not import any module that imports generated config. If `helpers/url.js` currently mixes server-only functions with pure URL functions, split it before wiring the universal barrel. - -### Proposed helper files - -The final helper structure uses folders with an `index.js` barrel per category. There is no top-level `helpers/index.js`: - -```txt -helpers/ - server/ - index.js # barrel: re-exports all server-only public helpers - fetch.js # aposFetch, aposPageFetch - url.js # getAposHost, isStaticBuild - universal/ - index.js # barrel: re-exports all universal public helpers - url.js # buildPageUrl, getFilterBaseUrl, aposSetQueryParameter - slug.js # slugify - styles.js # stylesElements, stylesAttributes (moved from lib/aposStyles.js) - attachment.js # getFocalPoint, getAttachmentUrl, etc. (moved from lib/attachment.js) - client/ - index.js # barrel: reserved for future browser-only helpers -``` - -### Package exports - -The exports map is intentionally restrictive — it exposes only what is considered public API. If a consumer needs something that is not listed, the right response is to deliver it through a proper public path in a subsequent release, not to access private code directly. This makes future internal refactoring possible without BC concerns. - -The map is a breaking change in the sense that any undocumented `lib/` imports in user projects will stop resolving. That is acceptable — those paths were never supported. - -The exports map must preserve the documented root import and documented component/widget imports: - -- `@apostrophecms/apostrophe-astro` -- `@apostrophecms/apostrophe-astro/helpers/server` -- `@apostrophecms/apostrophe-astro/helpers/universal` -- `@apostrophecms/apostrophe-astro/helpers/client` -- `@apostrophecms/apostrophe-astro/components/*` -- `@apostrophecms/apostrophe-astro/components/layouts/*` -- `@apostrophecms/apostrophe-astro/widgets/*` - -The integration also injects endpoint entry points by package path, so those must remain resolvable: - -- `@apostrophecms/apostrophe-astro/endpoints/aposProxy.js` -- `@apostrophecms/apostrophe-astro/endpoints/renderWidget.astro` - -### Deprecated shims - -The following `lib/` paths remain exported for backwards compatibility and receive JSDoc `@deprecated` notices pointing to `/helpers`: - -- `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js` -- `@apostrophecms/apostrophe-astro/lib/util` -- `@apostrophecms/apostrophe-astro/lib/util.js` -- `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter` -- `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js` - -Projects should migrate these imports to `@apostrophecms/apostrophe-astro/helpers/server` or `@apostrophecms/apostrophe-astro/helpers/universal`, depending on the helper. - -### Internal paths - -Other `lib/` paths are internal and are not listed in the exports map: - -- `lib/aposRequest.js` -- `lib/aposResponse.js` -- `lib/getAreaForApi.js` -- `lib/static.js` -- `lib/format.js` - -Projects importing these directly will need to move to `/helpers/server` or `/helpers/universal` where a public equivalent exists. - -`lib/aposStyles.js` and `lib/attachment.js` should be removed or reduced to temporary internal shims only if package internals still need them during the refactor. Their public implementations belong in `helpers/universal/styles.js` and `helpers/universal/attachment.js`. - -### README and starter-kit updates - -The README should explain the import contract and contribution rules directly: - -- Use `@apostrophecms/apostrophe-astro/helpers/server` in Astro frontmatter, endpoints, and other server-only code. -- Use `@apostrophecms/apostrophe-astro/helpers/universal` for utilities that work in both server and client contexts. -- Use `@apostrophecms/apostrophe-astro/helpers/client` for browser-only utilities that depend on `window`, `document`, or other browser APIs. -- There is no top-level `helpers` import — always use the explicit category path. -- Avoid importing from `@apostrophecms/apostrophe-astro/lib/*`; `lib/` is internal. - -The README must also document how to add a new helper so that developers and agents follow the same rules: - -- Classify the helper: does it import generated config, use `process.env`, or use a Node.js built-in unavailable in browsers? If yes, it is server-only (`helpers/server/`). Does it use browser APIs like `window` or `document`? If yes, it is client-only (`helpers/client/`). Otherwise it is universal (`helpers/universal/`). -- Add the implementation file to the correct category folder. -- Add a complete JSDoc block (`@param`, `@returns`, `@example`). -- Re-export the helper from that category's `index.js` barrel. - -Starter kits should be updated in the same release window so they no longer teach `lib/` imports. - -### TypeScript support - -All public helpers exported from `helpers/server/index.js`, `helpers/universal/index.js`, and `helpers/client/index.js` must have complete JSDoc annotations (`@param`, `@returns`, `@typedef` where needed). The package already uses JSDoc throughout — this extends that pattern consistently to the full public surface. Any existing JSDoc blocks should be inspected for correctness and full coverage. - -TypeScript declarations are generated from JSDoc using `tsc --declaration --allowJs --emitDeclarationOnly` and published with the package. The `package.json` `types` field points to the generated declarations. This gives TypeScript projects proper type checking and provides IntelliSense in VSCode for all projects regardless of whether they use TypeScript. - ---- - -## Migration Path for Existing Projects - -### Version bump - -This change ships as **v2.0.0**. - -Adding a `package.json` exports map is a breaking change because Node will reject imports of paths not listed in the map. A major version bump is required even if most projects do not need code changes. - -### What most projects need to do - -Most projects do not need to change integration configuration: - -- `apostropheIntegration()` options stay the same. -- `widgetsMapping`, `templatesMapping`, and `onBeforeWidgetRender` keep the same semantics. -- Component import paths stay the same. -- Injected routes stay the same. - -### Required and recommended changes - -Projects importing documented legacy `lib/` paths should migrate to the explicit helper entry points: - -```js -// Before -import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js'; -import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js'; - -// After -import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; -import { aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers/universal'; -``` - -Projects importing directly from `lib/aposStyles.js` or `lib/attachment.js` should also move to `/helpers`: - -```js -// Before -import { stylesAttributes } from '@apostrophecms/apostrophe-astro/lib/aposStyles.js'; -import { getAttachmentUrl } from '@apostrophecms/apostrophe-astro/lib/attachment.js'; - -// After -import { stylesAttributes, getAttachmentUrl } from '@apostrophecms/apostrophe-astro/helpers/universal'; -``` - -### Unsupported usage - -Projects importing `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` directly in their own Astro code were relying on private implementation details. Those imports are unsupported and must be removed. - -The generated `apostrophe-astro-config/*` specifiers are also private implementation details and should not be documented as public API. - ---- - -## Implementation Notes - -- Write the generated runtime files before internal code that imports them is transformed. -- Ensure generated files are recreated on every dev server start and build. -- Refactor static build cache from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/` in `lib/static.js`. -- Implement helper entry points and package exports in the same v2 release as the generated runtime files. -- Update README examples and starter kits in the same release window. -- Produce a `MIGRATION.md` file in the repo as a required v2 deliverable. The Migration Path section of this document is its basis. It should cover: all required import path changes, deprecated shim guidance, unsupported usage removal, and a confirmation that integration options and component paths are unchanged. -- Add focused tests or example-build coverage for: - - Astro 6 / Vite 7 dev startup. - - Astro 6 / Vite 7 static build. - - Extensionless directory mappings such as `./src/widgets`. - - Explicit file mappings. - - Missing mapping error messages. - - Optional `onBeforeWidgetRender`. -- Ensure all public helpers have complete JSDoc before running declaration generation. -- Run `tsc --declaration --allowJs --emitDeclarationOnly` as part of the release process and include generated `.d.ts` files in the published package via the `package.json` `types` field. - ---- - -## Main Risks - -- Generated files may need to exist earlier than `buildStart()` in Astro dev mode. -- Splitting `helpers/url.js` may accidentally change public behavior if not tested carefully. -- Adding an exports map may block undocumented imports in user projects. - ---- - -## Resolved Design Decisions - -- **`.gitignore`**: No special handling needed. Generated files live under `node_modules/`, which virtually all projects already ignore. -- **Generated file header**: Each generated file includes a header comment: `// AUTO-GENERATED by @apostrophecms/apostrophe-astro. Do not edit. This file is regenerated on every dev server start and build.` -- **Path computation**: All generated file paths and aliases are computed from `config.root`, not `process.cwd()`, ensuring portability across invocation contexts. -- **Static cache rename**: `lib/static.js` cache directory renamed from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/`. Internal change, no user impact. -- **TypeScript support**: Use complete JSDoc on all public helpers. Generate `.d.ts` via `tsc --declaration --allowJs --emitDeclarationOnly` and publish via `package.json` `types` field. Private generated module specifiers (`apostrophe-astro-config/config`, `apostrophe-astro-config/doctypes`) receive no published declarations as they are internal. From e9d94cd714a45db291bea9fefe973cc70b3a8faf Mon Sep 17 00:00:00 2001 From: Bob Means Date: Thu, 4 Jun 2026 06:07:51 -0400 Subject: [PATCH 07/39] Remove final design doc --- .../tech-design-astro-integration-v2.md | 501 ------------------ 1 file changed, 501 deletions(-) delete mode 100644 packages/apostrophe-astro/tech-design-astro-integration-v2.md diff --git a/packages/apostrophe-astro/tech-design-astro-integration-v2.md b/packages/apostrophe-astro/tech-design-astro-integration-v2.md deleted file mode 100644 index 0ed9e153b1..0000000000 --- a/packages/apostrophe-astro/tech-design-astro-integration-v2.md +++ /dev/null @@ -1,501 +0,0 @@ -# Tech Design: Apostrophe-Astro Integration v2 - -**Status:** Draft -**Date:** 2026-06-02 - ---- - -## Background and Motivation - -The `@apostrophecms/apostrophe-astro` integration currently relies on two Vite virtual modules to surface runtime configuration and doctype mappings to Astro components and helper code: - -- `virtual:apostrophe-config`: exports resolved integration config, including host, prefix, header lists, and static build flags. -- `virtual:apostrophe-doctypes`: re-exports the user-supplied widgets and templates mapping modules, plus the optional `onBeforeWidgetRender` hook. - -These virtual modules are implemented with Vite plugin `resolveId` and `load` hooks using the `\0`-prefixed internal module ID convention. - -Vite virtual modules are a documented Vite plugin pattern, but this integration's current use is fragile in newer Astro/Vite versions because the generated modules are consumed across Astro's server, prerender, and client-oriented processing paths. Astro 6 uses Vite 7 and Vite's Environment API internally, which makes the old assumptions around one consistent module graph less reliable. - -The goal of v2 is to remove these runtime virtual-module dependencies and use the same breaking release to define the package's public import surface clearly. - ---- - -## v2 Scope - -This release makes two coordinated breaking changes: - -1. **Generated runtime files** replace the current Vite virtual modules. This is the Astro 6 compatibility fix. -2. **Public helper import paths** are formalized. Public helpers move behind stable helper entry points, while `lib/` becomes internal implementation. - -These changes ship together in v2. There is no phased rollout. Starter kits and README examples should be updated in the same release window so project code moves to the final public import paths immediately. - ---- - -## Problem: Why the Current Virtual Modules Break - -### Astro now processes code through multiple Vite environments - -Astro 6 runs on Vite 7 and uses Vite's Environment API internally. During dev and build, Astro code can be processed for different purposes: server rendering, prerendering, client output, and framework/runtime integration work. - -The current integration assumes that an import of `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` will always be resolved through the same plugin pipeline and with the same surrounding module graph. That assumption is now too weak. Helpers and internal library modules are server-only, while components and endpoints can be transformed in several Astro build contexts. - -### The doctype virtual module is the riskiest part - -`vite-plugin-apostrophe-doctype.js` currently calls `this.resolve()` inside its `load()` hook to resolve the user's `widgetsMapping`, `templatesMapping`, and optional `onBeforeWidgetRender` files. It then synthesizes a module that imports those resolved IDs. - -That creates a fragile chain: - -1. Resolve user mapping IDs during virtual module load. -2. Emit synthetic import statements from the virtual module. -3. Rely on those emitted imports resolving again in the environment that consumes the virtual module. - -This is sensitive to when and where `load()` runs. Failures show up as missing `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` modules, failed widget/template lookup, or helper code being unable to read integration config. - -### The `\0` prefix is not the root bug - -The `\0` prefix is still a normal Vite virtual-module convention. The issue is not that Vite has removed support for virtual modules. The issue is that this package is using virtual modules to carry generated integration state and resolved user imports across Astro/Vite environments where real files are a better fit. - ---- - -## Implementation Area 1: Generated Runtime Files - -### Core idea - -Replace the virtual module plugins with generated real files: - -1. During `astro:config:setup`, write generated runtime files under `node_modules/.apostrophe-astro-config`. ⚠️ **Needs follow-up before v2 ships**: verify behavior in multisite setups. In a monorepo with pnpm hoisting, multiple Astro projects could share a `node_modules` and write to the same path. Using `config.root` from the setup hook instead of `process.cwd()` may be sufficient, but the multisite scenario needs to be confirmed. If namespacing is required, a per-project subdirectory keyed on `config.root` is the likely approach. -2. Point internal imports at those files with Vite aliases. -3. Keep using Vite's resolver for user-supplied mapping paths so extensionless directories, project aliases, and package exports continue to work. - -Real files on disk are easier for Vite, Astro, and Node to reason about than synthetic modules returned from `load()`. They are also directly inspectable when debugging a project. - -### Generated file location - -Files are written to a dedicated hidden directory: - -```txt -node_modules/ - .apostrophe-astro-config/ - config.js - doctypes.js -``` - -The static build cache in `lib/static.js` is also refactored in this release from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/`. This is a private internal change with no user-facing impact. It makes the naming convention consistent across all three directories and removes any risk of the static setup wipe accidentally deleting generated runtime files. - -The three directories and their responsibilities: - -- `node_modules/.apostrophe-astro-static/`: temporary static build cache owned by `lib/static.js`. -- `node_modules/.apostrophe-astro-config/`: generated runtime modules owned by the integration setup hook. - -No `.gitignore` change is needed because both directories live under `node_modules/`. - -### Generated file content - -Each generated file includes a header: - -```js -// AUTO-GENERATED by @apostrophecms/apostrophe-astro. Do not edit. -// This file is regenerated on every dev server start and build. -``` - -`config.js` serializes the resolved integration config as a static ES module export: - -```js -export default { - aposHost: "http://localhost:3000", - aposPrefix: "", - includeResponseHeaders: ["set-cookie"], - excludeRequestHeaders: [], - viewTransitionWorkaround: false, - staticBuild: null -}; -``` - -This is the same object currently produced by the `load()` handler of `vite-plugin-apostrophe-config.js`. - -`doctypes.js` re-exports the user's mapping modules: - -```js -import { default as widgets } from "../../src/widgets/index.js"; -import { default as templates } from "../../src/templates/index.js"; -import onBeforeWidgetRenderHookFn from "../../src/hooks/onBeforeWidgetRender.js"; - -export { widgets, templates }; -export const onBeforeWidgetRenderHook = onBeforeWidgetRenderHookFn; -``` - -When no hook is provided, `onBeforeWidgetRenderHook` exports `undefined` as before. - -### Resolving user mapping files - -The rewrite must preserve the current behavior of `widgetsMapping`, `templatesMapping`, and `onBeforeWidgetRender`. - -The README documents values like: - -```js -widgetsMapping: './src/widgets', -templatesMapping: './src/templates', -onBeforeWidgetRender: './src/hooks/before-widget-render.js' -``` - -Those paths may be extensionless directories, explicit files, package subpaths, or project aliases. A simple `path.resolve(process.cwd(), mapping)` is not equivalent to the current behavior. - -Implementation should resolve these mappings with a small internal Vite plugin that runs before normal module transformation. This preserves Vite/Rollup resolution behavior while removing the runtime virtual modules. - -The integration's `astro:config:setup` hook should register a setup-only Vite plugin: - -```js -function vitePluginApostropheGeneratedConfig(options, resolvedConfig) { - return { - name: 'vite-plugin-apostrophe-generated-config', - enforce: 'pre', - async buildStart() { - const resolvedWidgets = await this.resolve(options.widgetsMapping); - const resolvedTemplates = await this.resolve(options.templatesMapping); - const resolvedHook = options.onBeforeWidgetRender - ? await this.resolve(options.onBeforeWidgetRender) - : null; - - await writeGeneratedRuntimeFiles({ - config: resolvedConfig, - widgetsId: resolvedWidgets?.id, - templatesId: resolvedTemplates?.id, - hookId: resolvedHook?.id - }); - } - }; -} -``` - -This plugin replaces the two existing virtual-module plugins. It does not return generated module source from `load()`. Its only job is to use Vite's resolver, then write real files under `node_modules/.apostrophe-astro-config/`. - -The generated files are then consumed through aliases registered in the same `astro:config:setup` call. All paths are computed from `config.root` — the project root Astro is operating on — rather than `process.cwd()`. This keeps paths correct regardless of where `astro` is invoked from, and is portable across project renames and moves since the paths are recomputed fresh on every dev server start and build: - -```js -const generatedDir = path.join(config.root, 'node_modules/.apostrophe-astro-config'); - -updateConfig({ - vite: { - plugins: [ - vitePluginApostropheGeneratedConfig(options, resolvedConfig) - ], - resolve: { - alias: { - 'apostrophe-astro-config/config': path.join(generatedDir, 'config.js'), - 'apostrophe-astro-config/doctypes': path.join(generatedDir, 'doctypes.js') - } - } - } -}); -``` - -The aliases point directly to the generated files. That keeps internal imports deterministic even though the files are generated during startup. - -If Vite starts resolving internal imports before `buildStart()` writes the files in dev mode, the fix is not a simple move to `configResolved()`. The `configResolved()` hook does not provide `this.resolve()`, so extensionless directory paths, project aliases, and package subpaths in `widgetsMapping` and `templatesMapping` would not resolve correctly there. The likely solution is a `configureServer` hook for dev mode or an explicit two-pass approach. The implementation must verify timing across dev startup, SSR rendering, and static build startup, because the hook execution order differs between those paths. - -If resolution fails, the integration should throw an error that names the specific option that failed, for example: - -```txt -Could not resolve apostrophe-astro widgetsMapping: ./src/widgets -``` - -### Import specifiers - -Internal imports are updated from virtual module specifiers to internal alias specifiers: - -| Old | New | -| --- | --- | -| `virtual:apostrophe-config` | `apostrophe-astro-config/config` | -| `virtual:apostrophe-doctypes` | `apostrophe-astro-config/doctypes` | - -These specifiers remain internal implementation details. They are not documented for project code. - -All internal files that currently import `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` are updated: - -`apostrophe-astro-config/config`: - -- `lib/aposRequest.js` -- `lib/aposResponse.js` -- `lib/aposPageFetch.js` -- `helpers/fetch.js` -- `helpers/url.js` -- `components/layouts/AposLayout.astro` -- `components/layouts/AposEditLayout.astro` - -`apostrophe-astro-config/doctypes`: - -- `components/AposWidget.astro` -- `components/AposTemplate.astro` -- `endpoints/renderWidget.astro` - -### Hidden virtual import audit - -The implementation must remove all internal imports of: - -- `virtual:apostrophe-config` -- `virtual:apostrophe-doctypes` - -Current known import sites are: - -- `helpers/fetch.js` -- `helpers/url.js` -- `lib/aposPageFetch.js` -- `lib/aposRequest.js` -- `lib/aposResponse.js` -- `components/layouts/AposLayout.astro` -- `components/layouts/AposEditLayout.astro` -- `components/AposWidget.astro` -- `components/AposTemplate.astro` -- `endpoints/renderWidget.astro` - -The implementation should also search for `virtual:` in the package before release to catch any hidden or newly introduced virtual imports. - -### Tradeoffs - -| | Virtual modules (current) | Generated runtime files (proposed) | -| --- | --- | --- | -| Astro 6 / Vite 7 environments | Fragile for this use case | Real files resolve consistently | -| Debuggability | Module content invisible on disk | Files inspectable in `node_modules/.apostrophe-astro-config/` | -| Static cache interaction | Not applicable | Kept separate; static cache refactored to `.apostrophe-astro-static/` | -| Complexity | Two virtual-module plugins with `resolveId`/`load` | File generation plus mapping resolution | -| Cold start | No disk I/O | Writes two small files at config time | - ---- - -## Implementation Area 2: Public Helper Architecture - -### Problem with the current state - -The integration currently has no `package.json` exports map. Consumers can import any file path that happens to exist in the package, including internal `lib/` files. The README also documents at least one `lib/` import, `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js`, so the current public surface is partly accidental and partly documented. - -This v2 release should be the last breaking change around project helper imports. The package needs a clear rule: - -- `helpers/` is public. -- `lib/` is internal. -- Helpers are separated into three explicit categories by import path: server-only, universal, and client-only. -- There is no top-level `helpers` barrel — consumers must choose the correct path deliberately. -- README examples and starter kits use only public helper paths. - -### Public helper entry points - -The package exports three helper entry points. There is no top-level `helpers` barrel — all imports must use an explicit path: - -- `@apostrophecms/apostrophe-astro/helpers/server`: server-only helpers for Astro frontmatter, routes, endpoints, prerendering, and SSR. These import from generated config, `process.env`, or Node.js built-ins unavailable in browsers. -- `@apostrophecms/apostrophe-astro/helpers/universal`: helpers that work in both server and client contexts. These are pure functions with no environment dependencies. -- `@apostrophecms/apostrophe-astro/helpers/client`: browser-only helpers that depend on browser APIs such as `window` or `document`. Currently reserved — no helpers are in this category yet — but the path and taxonomy are established now for future use. - -### Helper classification - -Server-only public helpers: - -| Export | Source | Reason | -| --- | --- | --- | -| `aposPageFetch` | `lib/aposPageFetch.js` | Fetches Apostrophe page data through server request/response helpers and generated config | -| `aposFetch` | `helpers/fetch.js` | Prepends backend host and reads generated config | -| `getAposHost` | `helpers/server-url.js` | Exposes backend host from generated config | -| `isStaticBuild` | `helpers/server-url.js` | Reads generated config | - -Universal public helpers: - -| Export | Source | Reason | -| --- | --- | --- | -| `buildPageUrl` | `helpers/universal/url.js` | Pure URL construction from provided Apostrophe data | -| `getFilterBaseUrl` | `helpers/universal/url.js` | Pure data inspection | -| `aposSetQueryParameter` | `helpers/universal/url.js` | Pure URL manipulation | -| `slugify` | `helpers/universal/slug.js` | Pure string utility | -| `stylesElements` | `helpers/universal/styles.js` | Pure widget data helper moved from `lib/aposStyles.js` | -| `stylesAttributes` | `helpers/universal/styles.js` | Pure widget data helper moved from `lib/aposStyles.js` | -| `getFocalPoint` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | -| `getAttachmentUrl` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | -| `getAttachmentSrcset` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | -| `getWidth` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | -| `getHeight` | `helpers/universal/attachment.js` | Pure attachment data helper moved from `lib/attachment.js` | - -The `helpers/universal/index.js` barrel must not import any module that imports generated config. If `helpers/url.js` currently mixes server-only functions with pure URL functions, split it before wiring the universal barrel. - -### Proposed helper files - -The final helper structure uses folders with an `index.js` barrel per category. There is no top-level `helpers/index.js`: - -```txt -helpers/ - server/ - index.js # barrel: re-exports all server-only public helpers - fetch.js # aposFetch, aposPageFetch - url.js # getAposHost, isStaticBuild - universal/ - index.js # barrel: re-exports all universal public helpers - url.js # buildPageUrl, getFilterBaseUrl, aposSetQueryParameter - slug.js # slugify - styles.js # stylesElements, stylesAttributes (moved from lib/aposStyles.js) - attachment.js # getFocalPoint, getAttachmentUrl, etc. (moved from lib/attachment.js) - client/ - index.js # barrel: reserved for future browser-only helpers -``` - -### Package exports - -The exports map is intentionally restrictive — it exposes only what is considered public API. If a consumer needs something that is not listed, the right response is to deliver it through a proper public path in a subsequent release, not to access private code directly. This makes future internal refactoring possible without BC concerns. - -The map is a breaking change in the sense that any undocumented `lib/` imports in user projects will stop resolving. That is acceptable — those paths were never supported. - -The exports map must preserve the documented root import and documented component/widget imports: - -- `@apostrophecms/apostrophe-astro` -- `@apostrophecms/apostrophe-astro/helpers/server` -- `@apostrophecms/apostrophe-astro/helpers/universal` -- `@apostrophecms/apostrophe-astro/helpers/client` -- `@apostrophecms/apostrophe-astro/components/*` -- `@apostrophecms/apostrophe-astro/components/layouts/*` -- `@apostrophecms/apostrophe-astro/widgets/*` - -The integration also injects endpoint entry points by package path, so those must remain resolvable: - -- `@apostrophecms/apostrophe-astro/endpoints/aposProxy.js` -- `@apostrophecms/apostrophe-astro/endpoints/renderWidget.astro` - -### Deprecated shims - -The following `lib/` paths remain exported for backwards compatibility and receive JSDoc `@deprecated` notices pointing to `/helpers`: - -- `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js` -- `@apostrophecms/apostrophe-astro/lib/util` -- `@apostrophecms/apostrophe-astro/lib/util.js` -- `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter` -- `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js` - -Projects should migrate these imports to `@apostrophecms/apostrophe-astro/helpers/server` or `@apostrophecms/apostrophe-astro/helpers/universal`, depending on the helper. - -### Internal paths - -Other `lib/` paths are internal and are not listed in the exports map: - -- `lib/aposRequest.js` -- `lib/aposResponse.js` -- `lib/getAreaForApi.js` -- `lib/static.js` -- `lib/format.js` - -Projects importing these directly will need to move to `/helpers/server` or `/helpers/universal` where a public equivalent exists. - -`lib/aposStyles.js` and `lib/attachment.js` should be removed or reduced to temporary internal shims only if package internals still need them during the refactor. Their public implementations belong in `helpers/universal/styles.js` and `helpers/universal/attachment.js`. - -### README and starter-kit updates - -The README should explain the import contract and contribution rules directly: - -- Use `@apostrophecms/apostrophe-astro/helpers/server` in Astro frontmatter, endpoints, and other server-only code. -- Use `@apostrophecms/apostrophe-astro/helpers/universal` for utilities that work in both server and client contexts. -- Use `@apostrophecms/apostrophe-astro/helpers/client` for browser-only utilities that depend on `window`, `document`, or other browser APIs. -- There is no top-level `helpers` import — always use the explicit category path. -- Avoid importing from `@apostrophecms/apostrophe-astro/lib/*`; `lib/` is internal. - -The README must also document how to add a new helper so that developers and agents follow the same rules: - -- Classify the helper: does it import generated config, use `process.env`, or use a Node.js built-in unavailable in browsers? If yes, it is server-only (`helpers/server/`). Does it use browser APIs like `window` or `document`? If yes, it is client-only (`helpers/client/`). Otherwise it is universal (`helpers/universal/`). -- Add the implementation file to the correct category folder. -- Add a complete JSDoc block (`@param`, `@returns`, `@example`). -- Re-export the helper from that category's `index.js` barrel. - -Starter kits should be updated in the same release window so they no longer teach `lib/` imports. - -### TypeScript support - -All public helpers exported from `helpers/server/index.js`, `helpers/universal/index.js`, and `helpers/client/index.js` must have complete JSDoc annotations (`@param`, `@returns`, `@typedef` where needed). The package already uses JSDoc throughout — this extends that pattern consistently to the full public surface. - -TypeScript declarations are generated from JSDoc using `tsc --declaration --allowJs --emitDeclarationOnly` and published with the package. The `package.json` `types` field points to the generated declarations. This gives TypeScript projects proper type checking and provides IntelliSense in VSCode for all projects regardless of whether they use TypeScript. - ---- - -## Migration Path for Existing Projects - -### Version bump - -This change ships as **v2.0.0**. - -Adding a `package.json` exports map is a breaking change because Node will reject imports of paths not listed in the map. A major version bump is required even if most projects do not need code changes. - -### What most projects need to do - -Most projects do not need to change integration configuration: - -- `apostropheIntegration()` options stay the same. -- `widgetsMapping`, `templatesMapping`, and `onBeforeWidgetRender` keep the same semantics. -- Component import paths stay the same. -- Injected routes stay the same. - -### Required and recommended changes - -Projects importing documented legacy `lib/` paths should migrate to the explicit helper entry points: - -```js -// Before -import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js'; -import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js'; - -// After -import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; -import { aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers/universal'; -``` - -Projects importing directly from `lib/aposStyles.js` or `lib/attachment.js` should also move to `/helpers`: - -```js -// Before -import { stylesAttributes } from '@apostrophecms/apostrophe-astro/lib/aposStyles.js'; -import { getAttachmentUrl } from '@apostrophecms/apostrophe-astro/lib/attachment.js'; - -// After -import { stylesAttributes, getAttachmentUrl } from '@apostrophecms/apostrophe-astro/helpers/universal'; -``` - -### Unsupported usage - -Projects importing `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` directly in their own Astro code were relying on private implementation details. Those imports are unsupported and must be removed. - -The generated `apostrophe-astro-config/*` specifiers are also private implementation details and should not be documented as public API. - ---- - -## Implementation Notes - -- Write the generated runtime files before internal code that imports them is transformed. -- Ensure generated files are recreated on every dev server start and build. -- Refactor static build cache from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/` in `lib/static.js`. -- Implement helper entry points and package exports in the same v2 release as the generated runtime files. -- Update README examples and starter kits in the same release window. -- Produce a `MIGRATION.md` file in the repo as a required v2 deliverable. The Migration Path section of this document is its basis. It should cover: all required import path changes, deprecated shim guidance, unsupported usage removal, and a confirmation that integration options and component paths are unchanged. -- Add focused tests or example-build coverage for: - - Astro 6 / Vite 7 dev startup. - - Astro 6 / Vite 7 static build. - - Extensionless directory mappings such as `./src/widgets`. - - Explicit file mappings. - - Missing mapping error messages. - - Optional `onBeforeWidgetRender`. -- Ensure all public helpers have complete JSDoc before running declaration generation. -- Run `tsc --declaration --allowJs --emitDeclarationOnly` as part of the release process and include generated `.d.ts` files in the published package via the `package.json` `types` field. - ---- - -## Main Risks - -- Generated files may need to exist earlier than `buildStart()` in Astro dev mode. -- Splitting `helpers/url.js` may accidentally change public behavior if not tested carefully. -- Adding an exports map may block undocumented imports in user projects. - ---- - - -1. Is `buildStart()` the right Vite hook, or should this use `configResolved()` / another hook? -2. Are `helpers/server`, `helpers/client`, and `helpers` the right public import contract for v2? -3. Should any additional currently documented paths be included in the package exports map? -4. Should deprecated `lib/` shims remain for v2, or should v2 fully remove them? -5. Should any additional currently documented paths be included in the package exports map beyond those already listed? - -## Resolved Design Decisions - -- **`.gitignore`**: No special handling needed. Generated files live under `node_modules/`, which virtually all projects already ignore. -- **Generated file header**: Each generated file includes a header comment: `// AUTO-GENERATED by @apostrophecms/apostrophe-astro. Do not edit. This file is regenerated on every dev server start and build.` -- **Path computation**: All generated file paths and aliases are computed from `config.root`, not `process.cwd()`, ensuring portability across invocation contexts. -- **Static cache rename**: `lib/static.js` cache directory renamed from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/`. Internal change, no user impact. -- **TypeScript support**: Use complete JSDoc on all public helpers. Generate `.d.ts` via `tsc --declaration --allowJs --emitDeclarationOnly` and publish via `package.json` `types` field. Private generated module specifiers (`apostrophe-astro-config/config`, `apostrophe-astro-config/doctypes`) receive no published declarations as they are internal. From 99644c079440a83d3c9c9318ebc83d25446ac63e Mon Sep 17 00:00:00 2001 From: Bob Means Date: Thu, 4 Jun 2026 08:10:27 -0400 Subject: [PATCH 08/39] Better static cache root dir behavior and docs fix --- packages/apostrophe-astro/MIGRATION.md | 4 +- packages/apostrophe-astro/README.md | 4 +- packages/apostrophe-astro/index.js | 16 +++++-- packages/apostrophe-astro/lib/static.js | 46 +++++++++++++------ .../apostrophe-astro/types/lib/static.d.ts | 7 +++ ...te-plugin-apostrophe-generated-config.d.ts | 2 +- ...vite-plugin-apostrophe-generated-config.js | 5 +- 7 files changed, 57 insertions(+), 27 deletions(-) diff --git a/packages/apostrophe-astro/MIGRATION.md b/packages/apostrophe-astro/MIGRATION.md index 98110a9c89..5a6a84dbd6 100644 --- a/packages/apostrophe-astro/MIGRATION.md +++ b/packages/apostrophe-astro/MIGRATION.md @@ -82,7 +82,7 @@ import { stylesAttributes, getAttachmentUrl } from '@apostrophecms/apostrophe-as ## Deprecated shims (v2) -The following `lib/` paths remain exported in v2 as compatibility shims. They will log `@deprecated` JSDoc notices but continue to work. Migrate before v3. +The following `lib/` paths remain exported in v2 as compatibility shims. They continue to work, but new and migrated code should use the helper entry points. Migrate before v3. | Old path | New path | | --- | --- | @@ -103,7 +103,7 @@ If your project imports `virtual:apostrophe-config` or `virtual:apostrophe-docty ### Unlisted `lib/` paths -Any import of a `lib/` path not listed in the deprecated shims table above (e.g. `lib/aposRequest.js`, `lib/aposResponse.js`, `lib/format.js`, `lib/static.js`) will fail under the v2 exports map. These are internal modules with no public equivalent. If you need functionality that was only accessible via an internal path, open an issue to discuss adding a proper public helper. +Any import of a `lib/` path not listed in the deprecated shims table above (e.g. `lib/aposRequest.js`, `lib/aposResponse.js`, `lib/format.js`) will fail under the v2 exports map. These are internal modules with no public equivalent. If you need functionality that was only accessible via an internal path, open an issue to discuss adding a proper public helper. --- diff --git a/packages/apostrophe-astro/README.md b/packages/apostrophe-astro/README.md index 68387db454..73af868429 100644 --- a/packages/apostrophe-astro/README.md +++ b/packages/apostrophe-astro/README.md @@ -331,7 +331,7 @@ Your `[...slug].astro` component should look like this: ```js --- -import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js'; +import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; import AposLayout from '@apostrophecms/apostrophe-astro/components/layouts/AposLayout.astro'; import AposTemplate from '@apostrophecms/apostrophe-astro/components/AposTemplate.astro'; @@ -651,7 +651,7 @@ links to each page of blog posts: ```js --- -import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js'; +import { aposSetQueryParameter as setParameter } from '@apostrophecms/apostrophe-astro/helpers/universal'; const { pieces, diff --git a/packages/apostrophe-astro/index.js b/packages/apostrophe-astro/index.js index 1db28af839..10db81aeaa 100644 --- a/packages/apostrophe-astro/index.js +++ b/packages/apostrophe-astro/index.js @@ -2,6 +2,7 @@ import { vitePluginApostropheGeneratedConfig } from './vite/vite-plugin-apostrop import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { + setStaticCacheDir, writeConfigCache, writeLiteralContent, writeAttachments, @@ -134,11 +135,21 @@ export default function apostropheIntegration(options) { : (userStatic.attachmentScope || 'used') }; + // config.root is a URL in Astro 5+ — convert to a file-system path. + const projectRoot = config.root instanceof URL + ? fileURLToPath(config.root) + : config.root; + // Persist static build config so `lib/static.js` can read // it without depending on the Vite virtual module (which // is unavailable at config load time). if (isStaticBuild) { resolvedStaticBuild = staticBuild; + setStaticCacheDir(path.join( + projectRoot, + 'node_modules', + '.apostrophe-astro-static' + )); await writeConfigCache(staticBuild); } @@ -156,11 +167,6 @@ export default function apostropheIntegration(options) { staticBuild: isStaticBuild ? staticBuild : null }; - // config.root is a URL in Astro 5+ — convert to a file-system path. - const projectRoot = config.root instanceof URL - ? fileURLToPath(config.root) - : config.root; - const generatedDir = path.join( projectRoot, 'node_modules', diff --git a/packages/apostrophe-astro/lib/static.js b/packages/apostrophe-astro/lib/static.js index 11b6e00a65..bb4a409aa2 100644 --- a/packages/apostrophe-astro/lib/static.js +++ b/packages/apostrophe-astro/lib/static.js @@ -2,12 +2,28 @@ import { writeFile, mkdir, readFile, readdir, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { bgGreen, black, blue, dim, green, yellow, red, getTimeStat, timestamp } from './format.js'; -const CACHE_DIR = join(process.cwd(), 'node_modules', '.apostrophe-astro-static'); -const CONFIG_CACHE = join(CACHE_DIR, '_config.json'); -const ATTACHMENTS_CACHE = join(CACHE_DIR, '_attachments.json'); +let cacheDir = join(process.cwd(), 'node_modules', '.apostrophe-astro-static'); // Maximum number of concurrent attachment file downloads. const DOWNLOAD_CONCURRENCY = 5; +/** + * Override the static build cache directory. + * + * @internal + * @param {string} dir - Absolute cache directory path. + */ +export function setStaticCacheDir(dir) { + cacheDir = dir; +} + +function getConfigCachePath() { + return join(cacheDir, '_config.json'); +} + +function getAttachmentsCachePath() { + return join(cacheDir, '_attachments.json'); +} + /** * Persist static build configuration to the cache directory. * Cleans previous cache before writing. Called from the @@ -16,9 +32,9 @@ const DOWNLOAD_CONCURRENCY = 5; * @param {object} staticBuild - Resolved static build config. */ export async function writeConfigCache(staticBuild) { - await rm(CACHE_DIR, { recursive: true, force: true }).catch(() => {}); - await mkdir(CACHE_DIR, { recursive: true }); - await writeFile(CONFIG_CACHE, JSON.stringify(staticBuild)); + await rm(cacheDir, { recursive: true, force: true }).catch(() => {}); + await mkdir(cacheDir, { recursive: true }); + await writeFile(getConfigCachePath(), JSON.stringify(staticBuild)); } function authHeaders(key) { @@ -162,9 +178,9 @@ export async function getAllUrlMetadata(config) { // Cache literal content to the filesystem per locale so it can be // read by the `astro:build:done` hook without re-fetching. const cacheKey = locale || '_default'; - await mkdir(CACHE_DIR, { recursive: true }); + await mkdir(cacheDir, { recursive: true }); await writeFile( - join(CACHE_DIR, `${cacheKey}.json`), + join(cacheDir, `${cacheKey}.json`), JSON.stringify({ locale: locale || null, literalContent }) ); @@ -209,7 +225,7 @@ export async function getAllStaticPaths(config) { // written to the cache directory during `astro:config:setup`. let integrationConfig; try { - integrationConfig = JSON.parse(await readFile(CONFIG_CACHE, 'utf-8')); + integrationConfig = JSON.parse(await readFile(getConfigCachePath(), 'utf-8')); } catch { throw new Error( 'Static build config cache not found. The Apostrophe integration must run its ' + @@ -256,9 +272,9 @@ export async function getAllStaticPaths(config) { } // Cache deduplicated attachment metadata for the post-build hook - await mkdir(CACHE_DIR, { recursive: true }); + await mkdir(cacheDir, { recursive: true }); await writeFile( - ATTACHMENTS_CACHE, + getAttachmentsCachePath(), JSON.stringify({ uploadsUrl, results: [ ...attachmentMap.values() ] @@ -295,7 +311,7 @@ export async function writeLiteralContent({ aposHost, aposExternalFrontKey, outD const literalContent = []; let files; try { - files = await readdir(CACHE_DIR); + files = await readdir(cacheDir); } catch { throw new Error( 'Apostrophe static build cache not found. Ensure the `[...slug].astro` page calls ' + @@ -307,7 +323,7 @@ export async function writeLiteralContent({ aposHost, aposExternalFrontKey, outD continue; } const data = JSON.parse( - await readFile(join(CACHE_DIR, file), 'utf-8') + await readFile(join(cacheDir, file), 'utf-8') ); for (const entry of (data.literalContent || [])) { if (!seen.has(entry.url)) { @@ -401,7 +417,7 @@ export async function writeAttachments({ aposHost, outDir, logger, aposPrefix = const stats = { written: 0, warnings: 0, errors: 0 }; let cache; try { - cache = JSON.parse(await readFile(ATTACHMENTS_CACHE, 'utf-8')); + cache = JSON.parse(await readFile(getAttachmentsCachePath(), 'utf-8')); } catch { throw new Error( 'Apostrophe attachment cache not found. Ensure the `[...slug].astro` page calls ' + @@ -535,5 +551,5 @@ export function writePostBuildSummary({ literal, attachments, logger }) { } export async function cleanupCache() { - await rm(CACHE_DIR, { recursive: true, force: true }).catch(() => {}); + await rm(cacheDir, { recursive: true, force: true }).catch(() => {}); } diff --git a/packages/apostrophe-astro/types/lib/static.d.ts b/packages/apostrophe-astro/types/lib/static.d.ts index 2a0f698609..05d2582d3d 100644 --- a/packages/apostrophe-astro/types/lib/static.d.ts +++ b/packages/apostrophe-astro/types/lib/static.d.ts @@ -1,3 +1,10 @@ +/** + * Override the static build cache directory. + * + * @internal + * @param {string} dir - Absolute cache directory path. + */ +export function setStaticCacheDir(dir: string): void; /** * Persist static build configuration to the cache directory. * Cleans previous cache before writing. Called from the diff --git a/packages/apostrophe-astro/types/vite/vite-plugin-apostrophe-generated-config.d.ts b/packages/apostrophe-astro/types/vite/vite-plugin-apostrophe-generated-config.d.ts index 4de81728bb..7bdbdd1f46 100644 --- a/packages/apostrophe-astro/types/vite/vite-plugin-apostrophe-generated-config.d.ts +++ b/packages/apostrophe-astro/types/vite/vite-plugin-apostrophe-generated-config.d.ts @@ -24,4 +24,4 @@ * @param {string} projectRoot - Astro project root (`config.root`). * @returns {import('vite').Plugin} */ -export function vitePluginApostropheGeneratedConfig(options: import("../index.js").ApostropheIntegrationOptions, integrationConfig: object, projectRoot: string): any; +export function vitePluginApostropheGeneratedConfig(options: import("../index.js").ApostropheIntegrationOptions, integrationConfig: object, projectRoot: string): import("vite").Plugin; diff --git a/packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js b/packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js index 5cd4d3580c..fcdb0b1ff7 100644 --- a/packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js +++ b/packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js @@ -117,6 +117,7 @@ function extractId(result) { */ export function vitePluginApostropheGeneratedConfig(options, integrationConfig, projectRoot) { let filesWritten = false; + const rootImporter = join(projectRoot, 'astro.config.mjs'); /** * Resolve user mapping paths then write the generated files. @@ -174,7 +175,7 @@ export function vitePluginApostropheGeneratedConfig(options, integrationConfig, return async () => { if (!filesWritten) { await resolveAndWrite((id) => - server.pluginContainer.resolveId(id, undefined, { ssr: true }) + server.pluginContainer.resolveId(id, rootImporter, { ssr: true }) ); } }; @@ -186,7 +187,7 @@ export function vitePluginApostropheGeneratedConfig(options, integrationConfig, async buildStart() { if (!filesWritten) { const self = this; - await resolveAndWrite((id) => self.resolve(id)); + await resolveAndWrite((id) => self.resolve(id, rootImporter)); } } }; From ffaecffa6017c3bf13c698ec5396bb52254f8173 Mon Sep 17 00:00:00 2001 From: Bob Means Date: Fri, 5 Jun 2026 08:44:26 -0400 Subject: [PATCH 09/39] Bump dependancy --- packages/apostrophe-astro/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apostrophe-astro/package.json b/packages/apostrophe-astro/package.json index b82899e4fa..9b831985d6 100644 --- a/packages/apostrophe-astro/package.json +++ b/packages/apostrophe-astro/package.json @@ -48,7 +48,7 @@ "dependencies": { "lodash.deburr": "^4.1.0", "sluggo": "^1.0.0", - "undici": "^6.24.0" + "undici": "^7.27.1" }, "devDependencies": { "typescript": "^6.0.3" From ea8f0421577783cd27e10ec8be602e7e055673b1 Mon Sep 17 00:00:00 2001 From: Bob Means Date: Fri, 5 Jun 2026 15:27:27 -0400 Subject: [PATCH 10/39] Refactor for non-bc --- packages/apostrophe-astro/MIGRATION.md | 124 ------------------ .../components/layouts/AposLayout.astro | 5 +- .../apostrophe-astro/helpers/client/index.js | 10 -- packages/apostrophe-astro/helpers/index.js | 14 ++ .../apostrophe-astro/helpers/server/fetch.js | 14 +- .../apostrophe-astro/helpers/server/url.js | 2 +- packages/apostrophe-astro/index.js | 26 ++++ packages/apostrophe-astro/lib/aposRequest.js | 50 ++++++- packages/apostrophe-astro/lib/static.js | 59 +++++++-- packages/apostrophe-astro/package.json | 29 ---- .../apostrophe-astro/types/helpers/index.d.ts | 2 + .../apostrophe-astro/types/lib/static.d.ts | 4 + ...vite-plugin-apostrophe-generated-config.js | 42 +++++- 13 files changed, 192 insertions(+), 189 deletions(-) delete mode 100644 packages/apostrophe-astro/MIGRATION.md delete mode 100644 packages/apostrophe-astro/helpers/client/index.js create mode 100644 packages/apostrophe-astro/helpers/index.js create mode 100644 packages/apostrophe-astro/types/helpers/index.d.ts diff --git a/packages/apostrophe-astro/MIGRATION.md b/packages/apostrophe-astro/MIGRATION.md deleted file mode 100644 index 5a6a84dbd6..0000000000 --- a/packages/apostrophe-astro/MIGRATION.md +++ /dev/null @@ -1,124 +0,0 @@ -# Migrating to @apostrophecms/apostrophe-astro v2 - -## Overview - -v2 ships two coordinated breaking changes: - -1. **Generated runtime files** replace Vite virtual modules for Astro 6 / Vite 7 compatibility. -2. **Public helper import paths** are formalized. `helpers/server`, `helpers/universal`, and `helpers/client` are the new stable entry points. `lib/` is now internal. - -Most projects need only the import-path changes described below. Integration options, component paths, and injected routes are unchanged. - ---- - -## What stays the same - -- `apostropheIntegration()` options (`aposHost`, `widgetsMapping`, `templatesMapping`, `onBeforeWidgetRender`, `staticBuild`, etc.) are unchanged. -- Component import paths (`@apostrophecms/apostrophe-astro/components/*`, `.../components/layouts/*`, `.../widgets/*`) are unchanged. -- Injected routes (`/apos-frontend/[...slug]`, `/api/v1/[...slug]`, etc.) are unchanged. - ---- - -## Required changes - -### 1. Update `lib/aposPageFetch.js` imports - -```js -// Before -import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js'; - -// After -import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; -``` - -### 2. Update `lib/aposSetQueryParameter.js` imports - -```js -// Before -import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js'; -// or -import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter'; - -// After -import { aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers/universal'; -``` - -### 3. Update `lib/util` imports - -```js -// Before -import { slugify } from '@apostrophecms/apostrophe-astro/lib/util'; -// or -import { slugify } from '@apostrophecms/apostrophe-astro/lib/util.js'; - -// After -import { slugify } from '@apostrophecms/apostrophe-astro/helpers/universal'; -``` - -### 4. Update `lib/static.js` imports - -```js -// Before -import { getAllStaticPaths } from '@apostrophecms/apostrophe-astro/lib/static.js'; - -// After -import { getAllStaticPaths } from '@apostrophecms/apostrophe-astro/helpers/server'; -``` - -### 5. Update `lib/aposStyles.js` and `lib/attachment.js` imports - -These files are no longer part of the public API. - -```js -// Before -import { stylesAttributes } from '@apostrophecms/apostrophe-astro/lib/aposStyles.js'; -import { getAttachmentUrl } from '@apostrophecms/apostrophe-astro/lib/attachment.js'; - -// After -import { stylesAttributes, getAttachmentUrl } from '@apostrophecms/apostrophe-astro/helpers/universal'; -``` - ---- - -## Deprecated shims (v2) - -The following `lib/` paths remain exported in v2 as compatibility shims. They continue to work, but new and migrated code should use the helper entry points. Migrate before v3. - -| Old path | New path | -| --- | --- | -| `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js` | `@apostrophecms/apostrophe-astro/helpers/server` | -| `@apostrophecms/apostrophe-astro/lib/static.js` | `@apostrophecms/apostrophe-astro/helpers/server` | -| `@apostrophecms/apostrophe-astro/lib/util` | `@apostrophecms/apostrophe-astro/helpers/universal` | -| `@apostrophecms/apostrophe-astro/lib/util.js` | `@apostrophecms/apostrophe-astro/helpers/universal` | -| `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter` | `@apostrophecms/apostrophe-astro/helpers/universal` | -| `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js` | `@apostrophecms/apostrophe-astro/helpers/universal` | - ---- - -## Unsupported usage that must be removed - -### Virtual module imports - -If your project imports `virtual:apostrophe-config` or `virtual:apostrophe-doctypes` directly, those imports are unsupported and must be removed. These were private implementation details and have been replaced by generated files that are not part of the public API. - -### Unlisted `lib/` paths - -Any import of a `lib/` path not listed in the deprecated shims table above (e.g. `lib/aposRequest.js`, `lib/aposResponse.js`, `lib/format.js`) will fail under the v2 exports map. These are internal modules with no public equivalent. If you need functionality that was only accessible via an internal path, open an issue to discuss adding a proper public helper. - ---- - -## Helper import contract - -| Import path | Use in | -| --- | --- | -| `@apostrophecms/apostrophe-astro/helpers/server` | Astro frontmatter, server endpoints, SSR routes, prerendering. Depends on generated config and Node.js built-ins — do not use in client scripts. | -| `@apostrophecms/apostrophe-astro/helpers/universal` | Utilities that work in both server and client contexts. Pure functions only — no generated config, no `process.env`, no Node.js built-ins. | -| `@apostrophecms/apostrophe-astro/helpers/client` | Reserved for future browser-only helpers. Empty in v2. | - -There is no top-level `helpers` barrel — always use one of the three explicit category paths. - ---- - -## Static build cache directory - -The static build cache has moved from `node_modules/.apostrophe-astro/` to `node_modules/.apostrophe-astro-static/`. Both directories live under `node_modules/` and require no `.gitignore` changes. This is an internal implementation detail with no user-facing impact. diff --git a/packages/apostrophe-astro/components/layouts/AposLayout.astro b/packages/apostrophe-astro/components/layouts/AposLayout.astro index 22305dd663..ec2011ca65 100644 --- a/packages/apostrophe-astro/components/layouts/AposLayout.astro +++ b/packages/apostrophe-astro/components/layouts/AposLayout.astro @@ -13,7 +13,10 @@ if (!headersToInclude && config.forwardHeaders) { headersToInclude = config.forwardHeaders; } -if (headersToInclude && Array.isArray(headersToInclude)) { +// Response headers are only meaningful in SSR — prerendered static pages +// have no per-request HTTP response, and accessing Astro.response.headers +// in that context triggers a warning in Astro v6. +if (!config.staticBuild && headersToInclude && Array.isArray(headersToInclude)) { const headers = aposData.aposResponseHeaders; if (headers) { for (const header of headersToInclude) { diff --git a/packages/apostrophe-astro/helpers/client/index.js b/packages/apostrophe-astro/helpers/client/index.js deleted file mode 100644 index 8c5b63ad83..0000000000 --- a/packages/apostrophe-astro/helpers/client/index.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Client-only public helpers for @apostrophecms/apostrophe-astro. - * - * Reserved for future browser-only helpers that depend on browser APIs - * such as `window` or `document`. No helpers are in this category yet. - * - * Do not add server-side or Node.js-dependent code here. - * - * @module @apostrophecms/apostrophe-astro/helpers/client - */ diff --git a/packages/apostrophe-astro/helpers/index.js b/packages/apostrophe-astro/helpers/index.js new file mode 100644 index 0000000000..db816d6af0 --- /dev/null +++ b/packages/apostrophe-astro/helpers/index.js @@ -0,0 +1,14 @@ +/** + * @deprecated Import from the scoped sub-paths instead: + * + * ```js + * // Server-side helpers (Astro frontmatter, endpoints, prerendering) + * import { aposFetch, getAposHost, isStaticBuild, getAllStaticPaths } from '@apostrophecms/apostrophe-astro/helpers/server'; + * + * // Universal helpers (server + client) + * import { slugify, getAttachmentUrl, aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * ``` + */ + +export { aposFetch, aposPageFetch, getAposHost, isStaticBuild, getAllStaticPaths, getAllUrlMetadata, getLocales } from './server/index.js'; +export { buildPageUrl, getFilterBaseUrl, aposSetQueryParameter, slugify, stylesElements, stylesAttributes, getFocalPoint, getAttachmentUrl, getAttachmentSrcset, getWidth, getHeight } from './universal/index.js'; diff --git a/packages/apostrophe-astro/helpers/server/fetch.js b/packages/apostrophe-astro/helpers/server/fetch.js index 0e2e454a9e..f6788b61da 100644 --- a/packages/apostrophe-astro/helpers/server/fetch.js +++ b/packages/apostrophe-astro/helpers/server/fetch.js @@ -1,7 +1,7 @@ import config from 'apostrophe-astro-config/config'; import { getAposHost } from './url.js'; import aposResponse from '../../lib/aposResponse.js'; -import aposRequest from '../../lib/aposRequest.js'; +import aposRequest, { isAstroPrerenderedRequest } from '../../lib/aposRequest.js'; /** * A transparent proxy around the native `fetch` API for **server-side @@ -80,7 +80,17 @@ export async function aposFetch(input, init) { export async function aposPageFetch(req) { let aposData = {}; try { - let request = aposRequest(req); + // Pass only the URL (as a plain string) when the request is Astro's + // prerendered request. Astro v6 installs a warning getter on + // Astro.request.headers for prerendered pages; we detect this via + // isAstroPrerenderedRequest() which inspects the property descriptor + // without triggering the getter. Fall back to env-var / config checks + // for any edge cases where the request was already unwrapped. + const isStaticBuild = isAstroPrerenderedRequest(req) + || process.env.APOS_ASTRO_STATIC_BUILD === '1' + || Boolean(config.staticBuild); + const input = (isStaticBuild && req && typeof req !== 'string') ? req.url : req; + let request = aposRequest(input); if (request.method === 'HEAD') { request = new Request(request, { method: 'GET' diff --git a/packages/apostrophe-astro/helpers/server/url.js b/packages/apostrophe-astro/helpers/server/url.js index 0e4a53b2cf..d4c2c76ce0 100644 --- a/packages/apostrophe-astro/helpers/server/url.js +++ b/packages/apostrophe-astro/helpers/server/url.js @@ -51,5 +51,5 @@ export function getAposHost() { * ``` */ export function isStaticBuild() { - return Boolean(config.staticBuild); + return process.env.APOS_ASTRO_STATIC_BUILD === '1' || Boolean(config.staticBuild); } diff --git a/packages/apostrophe-astro/index.js b/packages/apostrophe-astro/index.js index 10db81aeaa..5c50f0e042 100644 --- a/packages/apostrophe-astro/index.js +++ b/packages/apostrophe-astro/index.js @@ -151,6 +151,11 @@ export default function apostropheIntegration(options) { '.apostrophe-astro-static' )); await writeConfigCache(staticBuild); + // Set a process-level flag so aposRequest can reliably detect + // the static build context across all Vite environments and + // module instances, avoiding Astro.request.headers access + // during prerendering which triggers a warning in Astro v6. + process.env.APOS_ASTRO_STATIC_BUILD = '1'; } // Build the integration config object that will be serialised @@ -182,11 +187,32 @@ export default function apostropheIntegration(options) { projectRoot ) ], + define: { + 'process.env.APOS_ASTRO_STATIC_BUILD': JSON.stringify(isStaticBuild ? '1' : '') + }, resolve: { alias: { 'apostrophe-astro-config/config': path.join(generatedDir, 'config.js'), 'apostrophe-astro-config/doctypes': path.join(generatedDir, 'doctypes.js') } + }, + // Ensure this package is always processed by Vite (not externalized) + // so that the generated-config aliases are applied. In Astro v6 the + // `vite.ssr` option only covers the SSR environment; the prerender + // environment used for static builds needs the same treatment via + // `vite.environments.prerender`. We set both for forward-compat. + ssr: { + noExternal: [ '@apostrophecms/apostrophe-astro' ] + }, + environments: { + prerender: { + resolve: { + noExternal: [ '@apostrophecms/apostrophe-astro' ] + }, + define: { + 'process.env.APOS_ASTRO_STATIC_BUILD': JSON.stringify(isStaticBuild ? '1' : '') + } + } } } }); diff --git a/packages/apostrophe-astro/lib/aposRequest.js b/packages/apostrophe-astro/lib/aposRequest.js index 3bc125cf6d..3494c0908f 100644 --- a/packages/apostrophe-astro/lib/aposRequest.js +++ b/packages/apostrophe-astro/lib/aposRequest.js @@ -1,17 +1,53 @@ import config from 'apostrophe-astro-config/config'; +// Astro v6 instruments Astro.request on prerendered pages by replacing the +// `headers` property with an own getter via Object.defineProperty. Calling +// that getter logs a warning. We detect this by inspecting the descriptor +// rather than reading the value, so no warning is triggered. +function isAstroPrerenderedRequest(req) { + if (!req || typeof req === 'string') return false; + const desc = Object.getOwnPropertyDescriptor(req, 'headers'); + return desc != null && typeof desc.get === 'function'; +} + +export { isAstroPrerenderedRequest }; + export default function(req) { - const request = new Request(req); const key = process.env.APOS_EXTERNAL_FRONT_KEY; if (!key) { throw new Error('APOS_EXTERNAL_FRONT_KEY environment variable must be set,\nhere and in the Apostrophe app'); } - request.headers.set('x-requested-with', 'AposExternalFront'); - request.headers.set('apos-external-front-key', key); - if (config.staticBuild) { - request.headers.set('x-apos-static-base-url', '1'); + + // Primary detection: inspect the request object directly. Astro v6 installs + // an own getter on Astro.request.headers for prerendered pages; a plain + // Request has headers on the prototype only. This is more reliable than + // env-var or config detection which can fail across Vite environments. + const isStaticBuild = isAstroPrerenderedRequest(req) + || process.env.APOS_ASTRO_STATIC_BUILD === '1' + || Boolean(config.staticBuild); + + let request; + if (isStaticBuild) { + // Static build: only use the URL — user headers are irrelevant for + // build-time server-to-server fetches and accessing req.headers on + // Astro.request during prerendering triggers a warning in Astro v6. + const url = typeof req === 'string' ? req : req.url; + request = new Request(url, { + headers: { + 'x-requested-with': 'AposExternalFront', + 'apos-external-front-key': key, + 'x-apos-static-base-url': '1' + } + }); + } else { + // SSR: clone the incoming request so user headers (cookies, locale, etc.) + // are forwarded to the Apostrophe backend. + request = new Request(req); + request.headers.set('x-requested-with', 'AposExternalFront'); + request.headers.set('apos-external-front-key', key); + // Prevent certain values of Connection, such as Upgrade, from causing an undici error in Node.js fetch + request.headers.delete('Connection'); } - // Prevent certain values of Connection, such as Upgrade, from causing an undici error in Node.js fetch - request.headers.delete('Connection'); + return request; } diff --git a/packages/apostrophe-astro/lib/static.js b/packages/apostrophe-astro/lib/static.js index bb4a409aa2..f844226564 100644 --- a/packages/apostrophe-astro/lib/static.js +++ b/packages/apostrophe-astro/lib/static.js @@ -2,12 +2,18 @@ import { writeFile, mkdir, readFile, readdir, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { bgGreen, black, blue, dim, green, yellow, red, getTimeStat, timestamp } from './format.js'; -let cacheDir = join(process.cwd(), 'node_modules', '.apostrophe-astro-static'); +// Initialized to null — must be set by the integration via setStaticCacheDir() +// before any cache-dependent functions are called. +let cacheDir = null; // Maximum number of concurrent attachment file downloads. const DOWNLOAD_CONCURRENCY = 5; /** * Override the static build cache directory. + * Must be called by the Apostrophe integration before any static-build + * functions are used. The integration sets this from `config.root` in its + * `astro:config:setup` hook so that the cache always lands inside the + * project's `node_modules`, regardless of where the Node process was started. * * @internal * @param {string} dir - Absolute cache directory path. @@ -16,12 +22,40 @@ export function setStaticCacheDir(dir) { cacheDir = dir; } +/** + * Resolve the cache directory for WRITE operations. Throws when + * `setStaticCacheDir` has not been called — writing to an unresolved path + * would silently land in the wrong place. + */ +function requireCacheDir() { + if (!cacheDir) { + throw new Error( + 'apostrophe-astro static cache directory has not been set. ' + + 'Ensure the Apostrophe integration is configured in astro.config.mjs ' + + 'so its astro:config:setup hook runs before any static-build helpers are called.' + ); + } + return cacheDir; +} + +/** + * Resolve the cache directory for READ operations. Falls back to + * `process.cwd()/node_modules/.apostrophe-astro-static` when + * `setStaticCacheDir` has not been called in this module instance — + * this is expected for `getStaticPaths` and post-build hooks, which + * run in a separate Vite context from the integration setup hook and + * rely on the on-disk cache written during `astro:config:setup`. + */ +function getCacheDir() { + return cacheDir ?? join(process.cwd(), 'node_modules', '.apostrophe-astro-static'); +} + function getConfigCachePath() { - return join(cacheDir, '_config.json'); + return join(getCacheDir(), '_config.json'); } function getAttachmentsCachePath() { - return join(cacheDir, '_attachments.json'); + return join(getCacheDir(), '_attachments.json'); } /** @@ -32,8 +66,9 @@ function getAttachmentsCachePath() { * @param {object} staticBuild - Resolved static build config. */ export async function writeConfigCache(staticBuild) { - await rm(cacheDir, { recursive: true, force: true }).catch(() => {}); - await mkdir(cacheDir, { recursive: true }); + const dir = requireCacheDir(); + await rm(dir, { recursive: true, force: true }).catch(() => {}); + await mkdir(dir, { recursive: true }); await writeFile(getConfigCachePath(), JSON.stringify(staticBuild)); } @@ -178,9 +213,10 @@ export async function getAllUrlMetadata(config) { // Cache literal content to the filesystem per locale so it can be // read by the `astro:build:done` hook without re-fetching. const cacheKey = locale || '_default'; - await mkdir(cacheDir, { recursive: true }); + const dir = getCacheDir(); + await mkdir(dir, { recursive: true }); await writeFile( - join(cacheDir, `${cacheKey}.json`), + join(dir, `${cacheKey}.json`), JSON.stringify({ locale: locale || null, literalContent }) ); @@ -272,7 +308,7 @@ export async function getAllStaticPaths(config) { } // Cache deduplicated attachment metadata for the post-build hook - await mkdir(cacheDir, { recursive: true }); + await mkdir(getCacheDir(), { recursive: true }); await writeFile( getAttachmentsCachePath(), JSON.stringify({ @@ -311,7 +347,7 @@ export async function writeLiteralContent({ aposHost, aposExternalFrontKey, outD const literalContent = []; let files; try { - files = await readdir(cacheDir); + files = await readdir(getCacheDir()); } catch { throw new Error( 'Apostrophe static build cache not found. Ensure the `[...slug].astro` page calls ' + @@ -323,7 +359,7 @@ export async function writeLiteralContent({ aposHost, aposExternalFrontKey, outD continue; } const data = JSON.parse( - await readFile(join(cacheDir, file), 'utf-8') + await readFile(join(getCacheDir(), file), 'utf-8') ); for (const entry of (data.literalContent || [])) { if (!seen.has(entry.url)) { @@ -551,5 +587,8 @@ export function writePostBuildSummary({ literal, attachments, logger }) { } export async function cleanupCache() { + if (!cacheDir) { + return; + } await rm(cacheDir, { recursive: true, force: true }).catch(() => {}); } diff --git a/packages/apostrophe-astro/package.json b/packages/apostrophe-astro/package.json index 9b831985d6..7cdd07bf74 100644 --- a/packages/apostrophe-astro/package.json +++ b/packages/apostrophe-astro/package.json @@ -14,35 +14,6 @@ "scripts": { "build:types": "tsc" }, - "exports": { - ".": { - "types": "./types/index.d.ts", - "default": "./index.js" - }, - "./helpers/server": { - "types": "./types/helpers/server/index.d.ts", - "default": "./helpers/server/index.js" - }, - "./helpers/universal": { - "types": "./types/helpers/universal/index.d.ts", - "default": "./helpers/universal/index.js" - }, - "./helpers/client": { - "types": "./types/helpers/client/index.d.ts", - "default": "./helpers/client/index.js" - }, - "./components/*.astro": "./components/*.astro", - "./components/layouts/*.astro": "./components/layouts/*.astro", - "./widgets/*.astro": "./widgets/*.astro", - "./endpoints/aposProxy.js": "./endpoints/aposProxy.js", - "./endpoints/renderWidget.astro": "./endpoints/renderWidget.astro", - "./lib/aposPageFetch.js": "./lib/aposPageFetch.js", - "./lib/static.js": "./lib/static.js", - "./lib/util": "./lib/util.js", - "./lib/util.js": "./lib/util.js", - "./lib/aposSetQueryParameter": "./lib/aposSetQueryParameter.js", - "./lib/aposSetQueryParameter.js": "./lib/aposSetQueryParameter.js" - }, "author": "Apostrophe Technologies", "license": "MIT", "dependencies": { diff --git a/packages/apostrophe-astro/types/helpers/index.d.ts b/packages/apostrophe-astro/types/helpers/index.d.ts new file mode 100644 index 0000000000..2a619397d2 --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/index.d.ts @@ -0,0 +1,2 @@ +export { aposFetch, aposPageFetch, getAposHost, isStaticBuild, getAllStaticPaths, getAllUrlMetadata, getLocales } from "./server/index.js"; +export { buildPageUrl, getFilterBaseUrl, aposSetQueryParameter, slugify, stylesElements, stylesAttributes, getFocalPoint, getAttachmentUrl, getAttachmentSrcset, getWidth, getHeight } from "./universal/index.js"; diff --git a/packages/apostrophe-astro/types/lib/static.d.ts b/packages/apostrophe-astro/types/lib/static.d.ts index 05d2582d3d..a46bfc618d 100644 --- a/packages/apostrophe-astro/types/lib/static.d.ts +++ b/packages/apostrophe-astro/types/lib/static.d.ts @@ -1,5 +1,9 @@ /** * Override the static build cache directory. + * Must be called by the Apostrophe integration before any static-build + * functions are used. The integration sets this from `config.root` in its + * `astro:config:setup` hook so that the cache always lands inside the + * project's `node_modules`, regardless of where the Node process was started. * * @internal * @param {string} dir - Absolute cache directory path. diff --git a/packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js b/packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js index fcdb0b1ff7..aa00b9237b 100644 --- a/packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js +++ b/packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js @@ -23,8 +23,36 @@ async function writeGeneratedRuntimeFiles({ templatesId, hookId }) { - const generatedDir = join(projectRoot, 'node_modules', '.apostrophe-astro-config'); - await mkdir(generatedDir, { recursive: true }); + // Write to two locations: + // + // 1. node_modules/.apostrophe-astro-config/ — dot-prefixed for Vite + // (conventional location for generated Vite artifacts; kept as the + // Vite alias target for belt-and-suspenders coverage in all Vite + // environments including dev server hot-reload). + // + // 2. node_modules/apostrophe-astro-config/ — a real Node-resolvable + // package so that `import 'apostrophe-astro-config/config'` works + // natively in Node's ESM loader without needing a Vite alias. + // This is essential for Astro v6 static builds where the prerender + // environment may execute page modules outside Vite's plugin chain. + const dotDir = join(projectRoot, 'node_modules', '.apostrophe-astro-config'); + const pkgDir = join(projectRoot, 'node_modules', 'apostrophe-astro-config'); + + await mkdir(dotDir, { recursive: true }); + await mkdir(pkgDir, { recursive: true }); + + // ── package.json (real package only) ────────────────────────────────────── + const pkgJson = JSON.stringify({ + name: 'apostrophe-astro-config', + version: '0.0.0', + type: 'module', + exports: { + './config': './config.js', + './doctypes': './doctypes.js' + } + }, null, 2) + '\n'; + + await writeFile(join(pkgDir, 'package.json'), pkgJson); // ── config.js ────────────────────────────────────────────────────────────── // Serialise the resolved integration config as a static ES module export. @@ -44,13 +72,16 @@ async function writeGeneratedRuntimeFiles({ JSON.stringify(configObj, null, 2) + ';\n'; - await writeFile(join(generatedDir, 'config.js'), configContent); + await writeFile(join(dotDir, 'config.js'), configContent); + await writeFile(join(pkgDir, 'config.js'), configContent); // ── doctypes.js ──────────────────────────────────────────────────────────── // Re-export the user's mapping modules using relative paths from the // generated directory so the file is portable across machines and moves. + // We use the dot-prefixed directory for relative path calculation since + // both directories are siblings and produce identical relative paths. function toRelative(absPath) { - let rel = relative(generatedDir, absPath).replace(/\\/g, '/'); + let rel = relative(dotDir, absPath).replace(/\\/g, '/'); if (!rel.startsWith('.')) { rel = './' + rel; } @@ -73,7 +104,8 @@ async function writeGeneratedRuntimeFiles({ '\nexport { widgets, templates };\n' + `export const onBeforeWidgetRenderHook = ${hookExport};\n`; - await writeFile(join(generatedDir, 'doctypes.js'), doctypesContent); + await writeFile(join(dotDir, 'doctypes.js'), doctypesContent); + await writeFile(join(pkgDir, 'doctypes.js'), doctypesContent); } /** From df33277ff5121e8d687101fb7b8fb02d40a7d2c8 Mon Sep 17 00:00:00 2001 From: Bob Means Date: Fri, 5 Jun 2026 15:34:48 -0400 Subject: [PATCH 11/39] Migration and version number --- packages/apostrophe-astro/MIGRATION.md | 64 ++++++++++++++++++++++++++ packages/apostrophe-astro/package.json | 2 +- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 packages/apostrophe-astro/MIGRATION.md diff --git a/packages/apostrophe-astro/MIGRATION.md b/packages/apostrophe-astro/MIGRATION.md new file mode 100644 index 0000000000..0eaec25b5c --- /dev/null +++ b/packages/apostrophe-astro/MIGRATION.md @@ -0,0 +1,64 @@ +# Migrating to @apostrophecms/apostrophe-astro v1.13 + +## Astro v6 support + +v1.13 adds support for Astro v6 (Vite 7). Astro v5 continues to work. + +--- + +## Astro v6: remove `security.allowedDomains` from static builds + +If your project uses `security.allowedDomains` in `astro.config.mjs` **and** runs static builds, guard it to SSR-only. In a static build Astro v6 reads `request.headers` to validate forwarded headers even during prerendering, producing a spurious warning for every page: + +``` +[WARN] `Astro.request.headers` was used when rendering the route `src/pages/[...slug].astro` +``` + +`allowedDomains` has no effect during prerendering (there are no real HTTP headers at build time), so the fix is straightforward: + +```js +// astro.config.mjs +const isStatic = process.env.APOS_BUILD === 'static'; // or however you detect it + +export default defineConfig({ + output: isStatic ? 'static' : 'server', + // Only configure allowedDomains for SSR — it is meaningless during + // static prerendering and triggers a spurious headers warning in Astro v6. + ...(!isStatic && { + security: { allowedDomains } + }), + // ... +}); +``` + +--- + +## Deprecated: direct `lib/` imports + +Imports of internal `lib/` paths still work in v1.13 but are deprecated and will be removed in a future major release. Migrate to the stable helper entry points: + +| Old import | New import | +|---|---| +| `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js` | `@apostrophecms/apostrophe-astro/helpers/server` (`aposPageFetch`) | +| `@apostrophecms/apostrophe-astro/lib/static.js` | `@apostrophecms/apostrophe-astro/helpers/server` (`getAllStaticPaths`, `getAllUrlMetadata`, `getLocales`) | +| `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js` | `@apostrophecms/apostrophe-astro/helpers/universal` (`aposSetQueryParameter`) | +| `@apostrophecms/apostrophe-astro/lib/util.js` | `@apostrophecms/apostrophe-astro/helpers/universal` (`slugify`, etc.) | +| `@apostrophecms/apostrophe-astro/lib/aposStyles.js` | `@apostrophecms/apostrophe-astro/helpers/universal` (`stylesAttributes`, `stylesElements`) | +| `@apostrophecms/apostrophe-astro/lib/attachment.js` | `@apostrophecms/apostrophe-astro/helpers/universal` (`getAttachmentUrl`, `getAttachmentSrcset`, etc.) | + +Example: + +```js +// Before +import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js'; +import { getAllStaticPaths } from '@apostrophecms/apostrophe-astro/lib/static.js'; + +// After +import { aposPageFetch, getAllStaticPaths } from '@apostrophecms/apostrophe-astro/helpers/server'; +``` + +--- + +## Removed: Vite virtual modules + +`virtual:apostrophe-config` and `virtual:apostrophe-doctypes` were private implementation details and are no longer available. If you were importing either of these directly, remove those imports — there is no public replacement, as they were never part of the supported API. diff --git a/packages/apostrophe-astro/package.json b/packages/apostrophe-astro/package.json index 7cdd07bf74..846a3284c6 100644 --- a/packages/apostrophe-astro/package.json +++ b/packages/apostrophe-astro/package.json @@ -1,6 +1,6 @@ { "name": "@apostrophecms/apostrophe-astro", - "version": "2.0.0", + "version": "1.13.0", "type": "module", "description": "Apostrophe integration for Astro", "repository": { From 2ab77f9e90fcffdd38803b5c89ad6787ad246b8b Mon Sep 17 00:00:00 2001 From: Bob Means Date: Sat, 6 Jun 2026 07:07:32 -0400 Subject: [PATCH 12/39] Response to comments. --- .changeset/six-socks-design.md | 12 +- packages/apostrophe-astro/MIGRATION.md | 8 +- packages/apostrophe-astro/helpers/index.js | 2 +- .../apostrophe-astro/helpers/server/fetch.js | 100 ----------------- .../apostrophe-astro/helpers/server/index.js | 2 +- .../apostrophe-astro/lib/aposPageFetch.js | 103 ++++++++++++++++-- packages/apostrophe-astro/package.json | 2 +- packages/apostrophe-astro/six-socks-design.md | 16 +++ .../types/helpers/client/index.d.ts | 0 .../apostrophe-astro/types/helpers/index.d.ts | 2 +- .../types/helpers/server/fetch.d.ts | 25 ----- .../types/helpers/server/index.d.ts | 2 +- .../types/lib/aposPageFetch.d.ts | 7 +- 13 files changed, 131 insertions(+), 150 deletions(-) create mode 100644 packages/apostrophe-astro/six-socks-design.md delete mode 100644 packages/apostrophe-astro/types/helpers/client/index.d.ts diff --git a/.changeset/six-socks-design.md b/.changeset/six-socks-design.md index 1f49bab3f6..b23cda4d77 100644 --- a/.changeset/six-socks-design.md +++ b/.changeset/six-socks-design.md @@ -1,16 +1,16 @@ --- -"@apostrophecms/apostrophe-astro": major +"@apostrophecms/apostrophe-astro": minor --- - Replace vite-plugin-apostrophe-config and vite-plugin-apostrophe-doctype with vite/vite-plugin-apostrophe-generated-config.js, which writes real files to node_modules/.apostrophe-astro-config/ (config.js, doctypes.js) - Register Vite aliases for apostrophe-astro-config/config and /doctypes -- Update all 10 internal virtual: imports to alias specifiers +- Update all internal virtual: imports to alias specifiers - Rename static build cache dir to node_modules/.apostrophe-astro-static/ -- Add helpers/server/ (aposFetch, aposPageFetch, getAposHost, isStaticBuild) +- Add helpers/server/ (aposFetch, getAposHost, isStaticBuild) - Add helpers/universal/ (URL, slug, styles, attachment helpers) -- Add helpers/client/index.js (reserved) -- Reduce lib/aposPageFetch.js, lib/util.js, lib/aposSetQueryParameter.js to deprecated shims -- Add package.json exports map; bump version to 2.0.0 +- Keep lib/aposPageFetch.js as the internal implementation (starter kit entrypoint only) +- Reduce lib/util.js, lib/aposSetQueryParameter.js, lib/static.js to deprecated shims - Add MIGRATION.md +- Bump undici to ^7.x for Node.js 24+ compatibility diff --git a/packages/apostrophe-astro/MIGRATION.md b/packages/apostrophe-astro/MIGRATION.md index 0eaec25b5c..96a90d440c 100644 --- a/packages/apostrophe-astro/MIGRATION.md +++ b/packages/apostrophe-astro/MIGRATION.md @@ -33,13 +33,12 @@ export default defineConfig({ --- -## Deprecated: direct `lib/` imports +## Deprecated: direct `lib/` imports for public helpers -Imports of internal `lib/` paths still work in v1.13 but are deprecated and will be removed in a future major release. Migrate to the stable helper entry points: +Some `lib/` paths are deprecated in favour of the stable helper entry points. Note that `lib/aposPageFetch.js` is **not** deprecated — it is an internal function used by the starter kit's `[...slug].astro` entrypoint and is not part of the public API. | Old import | New import | |---|---| -| `@apostrophecms/apostrophe-astro/lib/aposPageFetch.js` | `@apostrophecms/apostrophe-astro/helpers/server` (`aposPageFetch`) | | `@apostrophecms/apostrophe-astro/lib/static.js` | `@apostrophecms/apostrophe-astro/helpers/server` (`getAllStaticPaths`, `getAllUrlMetadata`, `getLocales`) | | `@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js` | `@apostrophecms/apostrophe-astro/helpers/universal` (`aposSetQueryParameter`) | | `@apostrophecms/apostrophe-astro/lib/util.js` | `@apostrophecms/apostrophe-astro/helpers/universal` (`slugify`, etc.) | @@ -50,11 +49,10 @@ Example: ```js // Before -import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js'; import { getAllStaticPaths } from '@apostrophecms/apostrophe-astro/lib/static.js'; // After -import { aposPageFetch, getAllStaticPaths } from '@apostrophecms/apostrophe-astro/helpers/server'; +import { getAllStaticPaths } from '@apostrophecms/apostrophe-astro/helpers/server'; ``` --- diff --git a/packages/apostrophe-astro/helpers/index.js b/packages/apostrophe-astro/helpers/index.js index db816d6af0..1fd6d8bfb3 100644 --- a/packages/apostrophe-astro/helpers/index.js +++ b/packages/apostrophe-astro/helpers/index.js @@ -10,5 +10,5 @@ * ``` */ -export { aposFetch, aposPageFetch, getAposHost, isStaticBuild, getAllStaticPaths, getAllUrlMetadata, getLocales } from './server/index.js'; +export { aposFetch, getAposHost, isStaticBuild, getAllStaticPaths, getAllUrlMetadata, getLocales } from './server/index.js'; export { buildPageUrl, getFilterBaseUrl, aposSetQueryParameter, slugify, stylesElements, stylesAttributes, getFocalPoint, getAttachmentUrl, getAttachmentSrcset, getWidth, getHeight } from './universal/index.js'; diff --git a/packages/apostrophe-astro/helpers/server/fetch.js b/packages/apostrophe-astro/helpers/server/fetch.js index f6788b61da..eae1466700 100644 --- a/packages/apostrophe-astro/helpers/server/fetch.js +++ b/packages/apostrophe-astro/helpers/server/fetch.js @@ -1,7 +1,5 @@ import config from 'apostrophe-astro-config/config'; import { getAposHost } from './url.js'; -import aposResponse from '../../lib/aposResponse.js'; -import aposRequest, { isAstroPrerenderedRequest } from '../../lib/aposRequest.js'; /** * A transparent proxy around the native `fetch` API for **server-side @@ -53,101 +51,3 @@ export async function aposFetch(input, init) { }); } -/** - * Fetch a full Apostrophe page data object for the given Astro request. - * - * This is the primary entry point for SSR and static-build page routes. - * It wraps `aposRequest` and `aposResponse` to forward the incoming - * request to the Apostrophe backend and return the parsed JSON page data, - * including automatic handling of trailing-slash redirects. - * - * For static builds, use this inside `getStaticPaths` / your page - * frontmatter to retrieve the `aposData` prop. - * - * @param {Request} req - The incoming Astro request (`Astro.request`). - * @returns {Promise} The Apostrophe page data object. On error, - * returns an object with `errorFetchingPage` set to the caught error - * and `page.type` set to `'apos-fetch-error'`. - * - * @example - * ```astro - * --- - * import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; - * const aposData = await aposPageFetch(Astro.request); - * --- - * ``` - */ -export async function aposPageFetch(req) { - let aposData = {}; - try { - // Pass only the URL (as a plain string) when the request is Astro's - // prerendered request. Astro v6 installs a warning getter on - // Astro.request.headers for prerendered pages; we detect this via - // isAstroPrerenderedRequest() which inspects the property descriptor - // without triggering the getter. Fall back to env-var / config checks - // for any edge cases where the request was already unwrapped. - const isStaticBuild = isAstroPrerenderedRequest(req) - || process.env.APOS_ASTRO_STATIC_BUILD === '1' - || Boolean(config.staticBuild); - const input = (isStaticBuild && req && typeof req !== 'string') ? req.url : req; - let request = aposRequest(input); - if (request.method === 'HEAD') { - request = new Request(request, { - method: 'GET' - }); - } - const response = await aposResponse(request); - let headers = response.headers; - aposData = await response.json(); - - // Apostrophe's external-front middleware returns redirects as JSON - // (e.g. { redirect: true, url: '/fr/', status: 302 }). When the - // redirect only adds or removes a trailing slash we should follow - // it internally rather than bouncing the browser — otherwise locale - // home pages like /fr/ cause an infinite redirect loop. - // Skip the site root "/" — it never needs this treatment. - // - // When a prefix is configured, Apostrophe returns redirect URLs - // without the prefix (it's a routing concern, not stored in page - // data). Strip the prefix from `from` so both sides compare on - // the same terms, then re-add it when constructing the retry URL. - if (aposData.redirect && aposData.url !== '/') { - const prefix = config.aposPrefix || ''; - let from = new URL(request.url).pathname.replace(/\/+$/, ''); - if (prefix && from.startsWith(prefix + '/')) { - from = from.slice(prefix.length); - } else if (prefix && from === prefix) { - from = '/'; - } - const to = (aposData.url || '').replace(/\/+$/, ''); - if (from === to) { - const retryUrl = prefix + aposData.url; - const retry = new Request(new URL(retryUrl, request.url), request); - const retryResponse = await aposResponse(retry); - headers = retryResponse.headers; - const retryData = await retryResponse.json(); - // Safety check: if the retry itself redirects to the same - // URL we just tried, we've hit an infinite redirect loop. - // Return an error instead of bouncing forever. - if (retryData.redirect && retryData.url === aposData.url) { - throw new Error( - `Infinite redirect detected: ${aposData.url} redirects back to itself` - ); - } - aposData = retryData; - } - } - - aposData.aposResponseHeaders = headers; - if (aposData.template === '@apostrophecms/page:notFound') { - aposData.notFound = true; - } - } catch (e) { - console.error('error:', e); - aposData.errorFetchingPage = e; - aposData.page = { - type: 'apos-fetch-error' - }; - } - return aposData; -} diff --git a/packages/apostrophe-astro/helpers/server/index.js b/packages/apostrophe-astro/helpers/server/index.js index d8b7481786..521836b2c5 100644 --- a/packages/apostrophe-astro/helpers/server/index.js +++ b/packages/apostrophe-astro/helpers/server/index.js @@ -9,6 +9,6 @@ * @module @apostrophecms/apostrophe-astro/helpers/server */ -export { aposFetch, aposPageFetch } from './fetch.js'; +export { aposFetch } from './fetch.js'; export { getAposHost, isStaticBuild } from './url.js'; export { getAllStaticPaths, getAllUrlMetadata, getLocales } from './static.js'; diff --git a/packages/apostrophe-astro/lib/aposPageFetch.js b/packages/apostrophe-astro/lib/aposPageFetch.js index 1a175ac33e..5f0fb540ce 100644 --- a/packages/apostrophe-astro/lib/aposPageFetch.js +++ b/packages/apostrophe-astro/lib/aposPageFetch.js @@ -1,11 +1,98 @@ +import config from 'apostrophe-astro-config/config'; +import aposResponse from './aposResponse.js'; +import aposRequest, { isAstroPrerenderedRequest } from './aposRequest.js'; + /** - * @deprecated Import from `@apostrophecms/apostrophe-astro/helpers/server` instead. + * Fetch a full Apostrophe page data object for the given Astro request. * - * ```js - * // Before - * import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js'; - * // After - * import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; - * ``` + * @internal + * This is for internal use by the starter kit's `[...slug].astro` entrypoint + * only. It is not part of the public helper API. For fetching arbitrary + * Apostrophe data in your own components, use `aposFetch` from + * `@apostrophecms/apostrophe-astro/helpers/server` instead. + * + * It wraps `aposRequest` and `aposResponse` to forward the incoming + * request to the Apostrophe backend and return the parsed JSON page data, + * including automatic handling of trailing-slash redirects. + * + * @param {Request} req - The incoming Astro request (`Astro.request`). + * @returns {Promise} The Apostrophe page data object. On error, + * returns an object with `errorFetchingPage` set to the caught error + * and `page.type` set to `'apos-fetch-error'`. */ -export { aposPageFetch as default } from '../helpers/server/fetch.js'; +export async function aposPageFetch(req) { + let aposData = {}; + try { + // Pass only the URL (as a plain string) when the request is Astro's + // prerendered request. Astro v6 installs a warning getter on + // Astro.request.headers for prerendered pages; we detect this via + // isAstroPrerenderedRequest() which inspects the property descriptor + // without triggering the getter. Fall back to env-var / config checks + // for any edge cases where the request was already unwrapped. + const isStaticBuild = isAstroPrerenderedRequest(req) + || process.env.APOS_ASTRO_STATIC_BUILD === '1' + || Boolean(config.staticBuild); + const input = (isStaticBuild && req && typeof req !== 'string') ? req.url : req; + let request = aposRequest(input); + if (request.method === 'HEAD') { + request = new Request(request, { + method: 'GET' + }); + } + const response = await aposResponse(request); + let headers = response.headers; + aposData = await response.json(); + + // Apostrophe's external-front middleware returns redirects as JSON + // (e.g. { redirect: true, url: '/fr/', status: 302 }). When the + // redirect only adds or removes a trailing slash we should follow + // it internally rather than bouncing the browser — otherwise locale + // home pages like /fr/ cause an infinite redirect loop. + // Skip the site root "/" — it never needs this treatment. + // + // When a prefix is configured, Apostrophe returns redirect URLs + // without the prefix (it's a routing concern, not stored in page + // data). Strip the prefix from `from` so both sides compare on + // the same terms, then re-add it when constructing the retry URL. + if (aposData.redirect && aposData.url !== '/') { + const prefix = config.aposPrefix || ''; + let from = new URL(request.url).pathname.replace(/\/+$/, ''); + if (prefix && from.startsWith(prefix + '/')) { + from = from.slice(prefix.length); + } else if (prefix && from === prefix) { + from = '/'; + } + const to = (aposData.url || '').replace(/\/+$/, ''); + if (from === to) { + const retryUrl = prefix + aposData.url; + const retry = new Request(new URL(retryUrl, request.url), request); + const retryResponse = await aposResponse(retry); + headers = retryResponse.headers; + const retryData = await retryResponse.json(); + // Safety check: if the retry itself redirects to the same + // URL we just tried, we've hit an infinite redirect loop. + // Return an error instead of bouncing forever. + if (retryData.redirect && retryData.url === aposData.url) { + throw new Error( + `Infinite redirect detected: ${aposData.url} redirects back to itself` + ); + } + aposData = retryData; + } + } + + aposData.aposResponseHeaders = headers; + if (aposData.template === '@apostrophecms/page:notFound') { + aposData.notFound = true; + } + } catch (e) { + console.error('error:', e); + aposData.errorFetchingPage = e; + aposData.page = { + type: 'apos-fetch-error' + }; + } + return aposData; +} + +export default aposPageFetch; diff --git a/packages/apostrophe-astro/package.json b/packages/apostrophe-astro/package.json index 846a3284c6..d1fe3868e6 100644 --- a/packages/apostrophe-astro/package.json +++ b/packages/apostrophe-astro/package.json @@ -1,6 +1,6 @@ { "name": "@apostrophecms/apostrophe-astro", - "version": "1.13.0", + "version": "1.12.0", "type": "module", "description": "Apostrophe integration for Astro", "repository": { diff --git a/packages/apostrophe-astro/six-socks-design.md b/packages/apostrophe-astro/six-socks-design.md new file mode 100644 index 0000000000..b23cda4d77 --- /dev/null +++ b/packages/apostrophe-astro/six-socks-design.md @@ -0,0 +1,16 @@ +--- +"@apostrophecms/apostrophe-astro": minor +--- + +- Replace vite-plugin-apostrophe-config and vite-plugin-apostrophe-doctype with + vite/vite-plugin-apostrophe-generated-config.js, which writes real files to + node_modules/.apostrophe-astro-config/ (config.js, doctypes.js) +- Register Vite aliases for apostrophe-astro-config/config and /doctypes +- Update all internal virtual: imports to alias specifiers +- Rename static build cache dir to node_modules/.apostrophe-astro-static/ +- Add helpers/server/ (aposFetch, getAposHost, isStaticBuild) +- Add helpers/universal/ (URL, slug, styles, attachment helpers) +- Keep lib/aposPageFetch.js as the internal implementation (starter kit entrypoint only) +- Reduce lib/util.js, lib/aposSetQueryParameter.js, lib/static.js to deprecated shims +- Add MIGRATION.md +- Bump undici to ^7.x for Node.js 24+ compatibility diff --git a/packages/apostrophe-astro/types/helpers/client/index.d.ts b/packages/apostrophe-astro/types/helpers/client/index.d.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/apostrophe-astro/types/helpers/index.d.ts b/packages/apostrophe-astro/types/helpers/index.d.ts index 2a619397d2..87a645e758 100644 --- a/packages/apostrophe-astro/types/helpers/index.d.ts +++ b/packages/apostrophe-astro/types/helpers/index.d.ts @@ -1,2 +1,2 @@ -export { aposFetch, aposPageFetch, getAposHost, isStaticBuild, getAllStaticPaths, getAllUrlMetadata, getLocales } from "./server/index.js"; +export { aposFetch, getAposHost, isStaticBuild, getAllStaticPaths, getAllUrlMetadata, getLocales } from "./server/index.js"; export { buildPageUrl, getFilterBaseUrl, aposSetQueryParameter, slugify, stylesElements, stylesAttributes, getFocalPoint, getAttachmentUrl, getAttachmentSrcset, getWidth, getHeight } from "./universal/index.js"; diff --git a/packages/apostrophe-astro/types/helpers/server/fetch.d.ts b/packages/apostrophe-astro/types/helpers/server/fetch.d.ts index fd9dbc8f91..0187647d67 100644 --- a/packages/apostrophe-astro/types/helpers/server/fetch.d.ts +++ b/packages/apostrophe-astro/types/helpers/server/fetch.d.ts @@ -32,28 +32,3 @@ * ``` */ export function aposFetch(input: string | URL | Request, init?: RequestInit): Promise; -/** - * Fetch a full Apostrophe page data object for the given Astro request. - * - * This is the primary entry point for SSR and static-build page routes. - * It wraps `aposRequest` and `aposResponse` to forward the incoming - * request to the Apostrophe backend and return the parsed JSON page data, - * including automatic handling of trailing-slash redirects. - * - * For static builds, use this inside `getStaticPaths` / your page - * frontmatter to retrieve the `aposData` prop. - * - * @param {Request} req - The incoming Astro request (`Astro.request`). - * @returns {Promise} The Apostrophe page data object. On error, - * returns an object with `errorFetchingPage` set to the caught error - * and `page.type` set to `'apos-fetch-error'`. - * - * @example - * ```astro - * --- - * import { aposPageFetch } from '@apostrophecms/apostrophe-astro/helpers/server'; - * const aposData = await aposPageFetch(Astro.request); - * --- - * ``` - */ -export function aposPageFetch(req: Request): Promise; diff --git a/packages/apostrophe-astro/types/helpers/server/index.d.ts b/packages/apostrophe-astro/types/helpers/server/index.d.ts index a36ca8c7fe..94d9c11ec3 100644 --- a/packages/apostrophe-astro/types/helpers/server/index.d.ts +++ b/packages/apostrophe-astro/types/helpers/server/index.d.ts @@ -1,3 +1,3 @@ -export { aposFetch, aposPageFetch } from "./fetch.js"; +export { aposFetch } from "./fetch.js"; export { getAposHost, isStaticBuild } from "./url.js"; export { getAllStaticPaths, getAllUrlMetadata, getLocales } from "./static.js"; diff --git a/packages/apostrophe-astro/types/lib/aposPageFetch.d.ts b/packages/apostrophe-astro/types/lib/aposPageFetch.d.ts index 6a6d8a6dde..6b655945bc 100644 --- a/packages/apostrophe-astro/types/lib/aposPageFetch.d.ts +++ b/packages/apostrophe-astro/types/lib/aposPageFetch.d.ts @@ -1 +1,6 @@ -export { aposPageFetch as default } from "../helpers/server/fetch.js"; +/** + * @internal For use in the starter kit's `[...slug].astro` entrypoint only. + * Not part of the public helper API. + */ +export declare function aposPageFetch(req: Request): Promise; +export default aposPageFetch; From 93157d96fa7dc45ab973005572bc1b03ae7ce4ec Mon Sep 17 00:00:00 2001 From: Bob Means Date: Sat, 6 Jun 2026 07:29:46 -0400 Subject: [PATCH 13/39] Deprecate old files --- packages/apostrophe-astro/lib/aposStyles.js | 80 +------ packages/apostrophe-astro/lib/attachment.js | 242 +------------------- 2 files changed, 21 insertions(+), 301 deletions(-) diff --git a/packages/apostrophe-astro/lib/aposStyles.js b/packages/apostrophe-astro/lib/aposStyles.js index 3d64c62748..a44394c7e7 100644 --- a/packages/apostrophe-astro/lib/aposStyles.js +++ b/packages/apostrophe-astro/lib/aposStyles.js @@ -1,69 +1,11 @@ -export function stylesElements(widget) { - return widget._options?.aposStylesElements || null; -} - -export function stylesAttributes(widget, additionalAttrs = {}) { - // Separate class and style from other additional attributes - const { - class: additionalClasses, - style: additionalStyle, - ...otherAttrs - } = additionalAttrs; - - if (additionalClasses) { - if (!Array.isArray(additionalClasses) && typeof additionalClasses !== 'string') { - console.warn('class must be a string or an array of strings'); - } - if (Array.isArray(additionalClasses) && !additionalClasses.every(cls => typeof cls === 'string')) { - console.warn('class array must contain only strings'); - } - } - if (additionalStyle && typeof additionalStyle !== 'string') { - console.warn('style must be a string'); - } - - const stylesAttrs = widget._options?.aposStylesAttributes || {}; - - const attrs = { ...stylesAttrs }; - - // Merge classes, keeping them unique - if (additionalClasses) { - const classSet = new Set(splitClasses(stylesAttrs.class)); - - const extraClasses = Array.isArray(additionalClasses) - ? additionalClasses - : splitClasses(additionalClasses); - - extraClasses.forEach(cls => classSet.add(cls)); - - if (classSet.size) { - attrs.class = Array.from(classSet).join(' '); - } - } - - // Merge styles - if (additionalStyle) { - attrs.style = - removeTrainlingSemicolon(attrs.style) + - removeTrainlingSemicolon(';' + additionalStyle); - } - - // Add other additional attributes - for (const [ key, value ] of Object.entries(otherAttrs)) { - if (value !== undefined && value !== null) { - attrs[key] = value; - } - } - - return attrs; -} - -function splitClasses(classes = '') { - return classes - .split(/\s+/) || [] - .filter(Boolean); -} - -function removeTrainlingSemicolon(style = '') { - return style.replace(/;$/, ''); -} +/** + * @deprecated Import from `@apostrophecms/apostrophe-astro/helpers/universal` instead. + * + * ```js + * // Before + * import { stylesElements, stylesAttributes } from '@apostrophecms/apostrophe-astro/lib/aposStyles.js'; + * // After + * import { stylesElements, stylesAttributes } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * ``` + */ +export { stylesElements, stylesAttributes } from '../helpers/universal/styles.js'; diff --git a/packages/apostrophe-astro/lib/attachment.js b/packages/apostrophe-astro/lib/attachment.js index fcba704a00..cdbdf98b6f 100644 --- a/packages/apostrophe-astro/lib/attachment.js +++ b/packages/apostrophe-astro/lib/attachment.js @@ -1,233 +1,11 @@ /** - * Utility functions for handling attachments and image related data. - */ - -const MISSING_ATTACHMENT_URL = '/images/missing-icon.svg'; - -/** - * Get the actual attachment object from either a full image object or direct attachment - * @param {Object} attachmentObject - Either a full image object or direct attachment - * @returns {Object|null} The attachment object - */ -function getAttachment(attachmentObject) { - if (!attachmentObject) return null; - - // If it's a full image object (has _fields), get its attachment - if (attachmentObject._fields) { - return attachmentObject.attachment; - } - - // If it's already an attachment or has nested attachment - return attachmentObject.attachment || attachmentObject; -} - -/** - * Check if attachment has multiple size variants - * @param {Object} attachmentObject - Either a full image object or direct attachment - * @returns {boolean} True if the attachment has multiple sizes - */ -function isSized(attachmentObject) { - const attachment = getAttachment(attachmentObject); - if (!attachment) return false; - - if (attachment._urls && typeof attachment._urls === 'object') { - return Object.keys(attachment._urls).length > 1; - } - - return false; -} - -/** - * Get focal point coordinates from attachment or image, or return default value if invalid - * @param {Object} attachmentObject - Either a full image object or direct attachment - * @param {string} [defaultValue='center center'] - Default value to return if no valid focal point - * @returns {string} String with focal point for styling (e.g., "50% 50%") or default value if invalid - */ -function getFocalPoint(attachmentObject, defaultValue = 'center center') { - if (!attachmentObject) return defaultValue; - - // Check _fields if it's from a relationship - if (attachmentObject._fields && - typeof attachmentObject._fields.x === 'number' && - attachmentObject._fields.x !== null && - typeof attachmentObject._fields.y === 'number' && - attachmentObject._fields.y !== null) { - return `${attachmentObject._fields.x}% ${attachmentObject._fields.y}%`; - } - - // Check attachment object directly if it's a direct attachment - const attachment = getAttachment(attachmentObject); - if (attachment && - typeof attachment.x === 'number' && - attachment.x !== null && - typeof attachment.y === 'number' && - attachment.y !== null) { - return `${attachment.x}% ${attachment.y}%`; - } - - return defaultValue; -} - -/** - * Get the width from the image object, using crop dimensions if available, - * otherwise falling back to original image dimensions - * @param {object} imageObject - Image object from ApostropheCMS - * @returns {number|undefined} The width of the image - */ -function getWidth(imageObject) { - // Use cropped width from _fields if available - if (imageObject?._fields?.width !== undefined && imageObject._fields.width !== null) { - return imageObject._fields.width; - } - // Fall back to original image width - return imageObject?.attachment?.width; -} - -/** - * Get the height from the image object, using crop dimensions if available, - * otherwise falling back to original image dimensions - * @param {object} imageObject - Image object from ApostropheCMS - * @returns {number|undefined} The height of the image - */ -function getHeight(imageObject) { - // Use cropped height from _fields if available - if (imageObject?._fields?.height !== undefined && imageObject._fields.height !== null) { - return imageObject._fields.height; - } - // Fall back to original image height - return imageObject?.attachment?.height; -} - -/** - * Get the crop parameters from the image object's _fields - * @param {Object} imageObject - The full image object from ApostropheCMS - * @returns {Object|null} The crop parameters or null if no crop exists - */ -function getCrop(imageObject) { - // Check for crop parameters in _fields - if (imageObject?._fields && - typeof imageObject._fields.left === 'number' && - typeof imageObject._fields.top === 'number' && - typeof imageObject._fields.width === 'number' && - typeof imageObject._fields.height === 'number') { - return { - left: imageObject._fields.left, - top: imageObject._fields.top, - width: imageObject._fields.width, - height: imageObject._fields.height - }; - } - - return null; -} - -/** - * Build the URL for an attachment with crop parameters and size - * @param {string} baseUrl - The base URL for the attachment - * @param {Object} crop - The crop parameters object - * @param {string} [size] - The size variant name - * @param {string} extension - The file extension - * @returns {string} The complete URL with crop parameters - */ -function buildAttachmentUrl(baseUrl, crop, size, extension) { - let url = baseUrl; - - // Add crop parameters if they exist - if (crop) { - url += `.${crop.left}.${crop.top}.${crop.width}.${crop.height}`; - } - - // Add size if specified - if (size && size !== 'original') { - url += `.${size}`; - } - - // Add extension - url += `.${extension}`; - - return url; -} - -/** - * Get URL for an attachment with optional size - * @param {Object} imageObject - The full image object from ApostropheCMS - * @param {Object} [options={}] - Options object - * @param {string} [options.size] - Size variant ('one-sixth', 'one-third', - * 'one-half', 'two-thirds', 'full', 'max', 'original') - * @param {string} [options.missingIcon] - Custom URL for missing attachment (optional) - * @returns {string} The URL for the attachment - */ -function getAttachmentUrl(imageObject, options = {}) { - const attachment = getAttachment(imageObject); - - if (!attachment) { - console.warn('Template warning: Missing attachment, using fallback icon'); - return options.missingIcon || MISSING_ATTACHMENT_URL; - } - - // Get the requested size or default to 'full' - const size = options.size || 'two-thirds'; - - // Check if we're in the just-edited state (has uncropped URLs) - if (attachment._urls?.uncropped) { - // During the just-edited state, the main _urls already contain the crop parameters - return attachment._urls[size] || attachment._urls.original; - } - - // Get crop parameters from the image object's _fields - const crop = getCrop(imageObject); - - // If we have _urls and no crop, use the pre-generated URL - if (attachment._urls && !crop) { - return attachment._urls[size] || attachment._urls.original; - } - - // Derive the base URL path from _urls if available - let baseUrl; - if (attachment._urls?.original) { - // Remove the extension from the original URL to get the base path - baseUrl = attachment._urls.original.replace(`.${attachment.extension}`, ''); - } - - // Build the complete URL with crop parameters and size - return buildAttachmentUrl(baseUrl, crop, size, attachment.extension); -} - -/** - * Generate a srcset for an image attachment - * @param {Object} attachmentObject - Either a full image object or direct attachment - * @param {Object} [options] - Options for generating the srcset - * @param {Array} [options.sizes] - Array of custom size objects to override the default sizes - * @param {string} options.sizes[].name - The name of the size (e.g., 'small', 'medium') - * @param {number} options.sizes[].width - The width of the image for this size - * @param {number} [options.sizes[].height] - The height of the image for this size (optional) - * @returns {string} The srcset string - */ -function getAttachmentSrcset(attachmentObject, options = {}) { - if (!attachmentObject || !isSized(attachmentObject)) { - return ''; - } - - const defaultSizes = [ - { name: 'one-sixth', width: 190, height: 350 }, - { name: 'one-third', width: 380, height: 700 }, - { name: 'one-half', width: 570, height: 700 }, - { name: 'two-thirds', width: 760, height: 760 }, - { name: 'full', width: 1140, height: 1140 }, - { name: 'max', width: 1600, height: 1600} - ]; - - const sizes = options.sizes || defaultSizes; - - return sizes - .map(size => `${getAttachmentUrl(attachmentObject, { ...options, size: size.name })} ${size.width}w`) - .join(', '); -} - -export { - getFocalPoint, - getWidth, - getHeight, - getAttachmentUrl, - getAttachmentSrcset -}; + * @deprecated Import from `@apostrophecms/apostrophe-astro/helpers/universal` instead. + * + * ```js + * // Before + * import { getAttachmentUrl, getAttachmentSrcset, getFocalPoint, getWidth, getHeight } from '@apostrophecms/apostrophe-astro/lib/attachment.js'; + * // After + * import { getAttachmentUrl, getAttachmentSrcset, getFocalPoint, getWidth, getHeight } from '@apostrophecms/apostrophe-astro/helpers/universal'; + * ``` + */ +export { getFocalPoint, getWidth, getHeight, getAttachmentUrl, getAttachmentSrcset } from '../helpers/universal/attachment.js'; From 564aabbe41149090278f4ebd2f4629b83cb4df04 Mon Sep 17 00:00:00 2001 From: Bob Means Date: Wed, 17 Jun 2026 09:18:20 -0400 Subject: [PATCH 14/39] Fixes upgrade header handling --- packages/apostrophe-astro/endpoints/aposProxy.js | 2 -- packages/apostrophe-astro/lib/aposRequest.js | 2 -- packages/apostrophe-astro/lib/aposResponse.js | 9 ++++++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/apostrophe-astro/endpoints/aposProxy.js b/packages/apostrophe-astro/endpoints/aposProxy.js index 6b31f23dd4..f3761230d1 100644 --- a/packages/apostrophe-astro/endpoints/aposProxy.js +++ b/packages/apostrophe-astro/endpoints/aposProxy.js @@ -2,8 +2,6 @@ import aposResponse from "../lib/aposResponse"; export async function ALL({ params, request, redirect }) { try { - // Prevent certain values of Connection, such as Upgrade, from causing an undici error in Node.js fetch - request.headers.delete('Connection'); const response = await aposResponse(request); if ([301, 302, 307, 308].includes(response.status)) { return redirect(response.headers.get('location'), response.status); diff --git a/packages/apostrophe-astro/lib/aposRequest.js b/packages/apostrophe-astro/lib/aposRequest.js index 3494c0908f..dc77822fbe 100644 --- a/packages/apostrophe-astro/lib/aposRequest.js +++ b/packages/apostrophe-astro/lib/aposRequest.js @@ -45,8 +45,6 @@ export default function(req) { request = new Request(req); request.headers.set('x-requested-with', 'AposExternalFront'); request.headers.set('apos-external-front-key', key); - // Prevent certain values of Connection, such as Upgrade, from causing an undici error in Node.js fetch - request.headers.delete('Connection'); } return request; diff --git a/packages/apostrophe-astro/lib/aposResponse.js b/packages/apostrophe-astro/lib/aposResponse.js index f4a09f9ac4..0324c0a0ac 100644 --- a/packages/apostrophe-astro/lib/aposResponse.js +++ b/packages/apostrophe-astro/lib/aposResponse.js @@ -50,10 +50,17 @@ export default async function aposResponse(req) { const aposUrl = new URL(aposHost + pathname); aposUrl.search = url.search; + // Headers that undici rejects unconditionally — strip them before + // forwarding to the backend regardless of user configuration. + // `Connection: Upgrade` and a bare `Upgrade` header both trigger + // UND_ERR_INVALID_ARG in undici when passed through a proxy. + const undiciRejectedHeaders = new Set([ 'connection', 'upgrade' ]); + // Prepare headers, excluding any specified in config const requestHeaders = {}; for (const [name, value] of req.headers) { - if (!excludedHeadersLower.has(name.toLowerCase())) { + const lower = name.toLowerCase(); + if (!excludedHeadersLower.has(lower) && !undiciRejectedHeaders.has(lower)) { requestHeaders[name] = value; } } From 32f7a9aa73489b19606717dbcd19a1160a2d010f Mon Sep 17 00:00:00 2001 From: Bob Means Date: Thu, 18 Jun 2026 06:32:17 -0400 Subject: [PATCH 15/39] Add tests --- packages/apostrophe-astro/.mocharc.yml | 4 + packages/apostrophe-astro/package.json | 5 +- .../test/helpers/universal/attachment.test.js | 135 +++++++++++ .../test/helpers/universal/slug.test.js | 35 +++ .../test/helpers/universal/styles.test.js | 72 ++++++ .../test/helpers/universal/url.test.js | 99 +++++++++ .../test/lib/aposRequest.test.js | 115 ++++++++++ .../test/lib/aposResponse.test.js | 210 ++++++++++++++++++ packages/apostrophe-astro/test/setup.js | 41 ++++ 9 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 packages/apostrophe-astro/.mocharc.yml create mode 100644 packages/apostrophe-astro/test/helpers/universal/attachment.test.js create mode 100644 packages/apostrophe-astro/test/helpers/universal/slug.test.js create mode 100644 packages/apostrophe-astro/test/helpers/universal/styles.test.js create mode 100644 packages/apostrophe-astro/test/helpers/universal/url.test.js create mode 100644 packages/apostrophe-astro/test/lib/aposRequest.test.js create mode 100644 packages/apostrophe-astro/test/lib/aposResponse.test.js create mode 100644 packages/apostrophe-astro/test/setup.js diff --git a/packages/apostrophe-astro/.mocharc.yml b/packages/apostrophe-astro/.mocharc.yml new file mode 100644 index 0000000000..469cb6f5ad --- /dev/null +++ b/packages/apostrophe-astro/.mocharc.yml @@ -0,0 +1,4 @@ +loader: esmock +file: test/setup.js +spec: test/**/*.test.js +timeout: 5000 diff --git a/packages/apostrophe-astro/package.json b/packages/apostrophe-astro/package.json index d1fe3868e6..450d1d0d2c 100644 --- a/packages/apostrophe-astro/package.json +++ b/packages/apostrophe-astro/package.json @@ -12,7 +12,8 @@ "main": "index.js", "types": "types/index.d.ts", "scripts": { - "build:types": "tsc" + "build:types": "tsc", + "test": "mocha" }, "author": "Apostrophe Technologies", "license": "MIT", @@ -22,6 +23,8 @@ "undici": "^7.27.1" }, "devDependencies": { + "esmock": "^2.7.6", + "mocha": "^11.7.6", "typescript": "^6.0.3" } } diff --git a/packages/apostrophe-astro/test/helpers/universal/attachment.test.js b/packages/apostrophe-astro/test/helpers/universal/attachment.test.js new file mode 100644 index 0000000000..e2231b2515 --- /dev/null +++ b/packages/apostrophe-astro/test/helpers/universal/attachment.test.js @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { + getFocalPoint, + getWidth, + getHeight, + getAttachmentUrl, + getAttachmentSrcset +} from '../../../helpers/universal/attachment.js'; + +// Minimal attachment fixture +function makeImage({ x, y, width, height, _urls, extension = 'jpg' } = {}) { + return { + attachment: { + _urls: _urls || { original: `/uploads/test.${extension}`, 'two-thirds': `/uploads/test.two-thirds.${extension}` }, + extension, + width: width || 800, + height: height || 600, + ...(x != null ? { x } : {}), + ...(y != null ? { y } : {}) + } + }; +} + +describe('getFocalPoint', () => { + it('returns default when attachment is null', () => { + assert.equal(getFocalPoint(null), 'center center'); + }); + + it('returns a custom default value', () => { + assert.equal(getFocalPoint(null, '50% 50%'), '50% 50%'); + }); + + it('returns _fields focal point when present', () => { + const image = { _fields: { x: 30, y: 70 }, attachment: {} }; + assert.equal(getFocalPoint(image), '30% 70%'); + }); + + it('returns attachment-level focal point when no _fields', () => { + const image = makeImage({ x: 20, y: 80 }); + assert.equal(getFocalPoint(image), '20% 80%'); + }); + + it('returns default when x/y are null', () => { + const image = { _fields: { x: null, y: null }, attachment: {} }; + assert.equal(getFocalPoint(image), 'center center'); + }); +}); + +describe('getWidth', () => { + it('returns _fields.width when available', () => { + const image = { _fields: { width: 400, height: 300 }, attachment: { width: 800 } }; + assert.equal(getWidth(image), 400); + }); + + it('falls back to attachment.width', () => { + assert.equal(getWidth(makeImage({ width: 800 })), 800); + }); + + it('returns undefined for missing image', () => { + assert.equal(getWidth(null), undefined); + }); +}); + +describe('getHeight', () => { + it('returns _fields.height when available', () => { + const image = { _fields: { width: 400, height: 300 }, attachment: { height: 600 } }; + assert.equal(getHeight(image), 300); + }); + + it('falls back to attachment.height', () => { + assert.equal(getHeight(makeImage({ height: 600 })), 600); + }); +}); + +describe('getAttachmentUrl', () => { + it('returns missing icon when no attachment', () => { + assert.equal(getAttachmentUrl(null), '/images/missing-icon.svg'); + }); + + it('returns custom missing icon when provided', () => { + assert.equal( + getAttachmentUrl(null, { missingIcon: '/custom/missing.svg' }), + '/custom/missing.svg' + ); + }); + + it('returns the two-thirds URL by default', () => { + const image = makeImage(); + assert.equal(getAttachmentUrl(image), '/uploads/test.two-thirds.jpg'); + }); + + it('returns the requested size URL', () => { + const image = { + attachment: { + _urls: { + original: '/uploads/test.jpg', + full: '/uploads/test.full.jpg', + 'two-thirds': '/uploads/test.two-thirds.jpg' + }, + extension: 'jpg' + } + }; + assert.equal(getAttachmentUrl(image, { size: 'full' }), '/uploads/test.full.jpg'); + }); + + it('builds URL with crop parameters from _fields', () => { + const image = { + _fields: { left: 10, top: 20, width: 200, height: 300 }, + attachment: { + _urls: { original: '/uploads/test.jpg', 'two-thirds': '/uploads/test.two-thirds.jpg' }, + extension: 'jpg' + } + }; + const url = getAttachmentUrl(image, { size: 'two-thirds' }); + assert.equal(url, '/uploads/test.10.20.200.300.two-thirds.jpg'); + }); +}); + +describe('getAttachmentSrcset', () => { + it('returns empty string when no attachment', () => { + assert.equal(getAttachmentSrcset(null), ''); + }); + + it('returns empty string when attachment has only one size (e.g. SVG)', () => { + const image = { attachment: { _urls: { original: '/uploads/icon.svg' } } }; + assert.equal(getAttachmentSrcset(image), ''); + }); + + it('returns a srcset string with width descriptors', () => { + const image = makeImage(); + const srcset = getAttachmentSrcset(image); + assert.match(srcset, /\d+w/); + assert.ok(srcset.includes(','), 'srcset should have multiple entries'); + }); +}); diff --git a/packages/apostrophe-astro/test/helpers/universal/slug.test.js b/packages/apostrophe-astro/test/helpers/universal/slug.test.js new file mode 100644 index 0000000000..cfb50b0ed5 --- /dev/null +++ b/packages/apostrophe-astro/test/helpers/universal/slug.test.js @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { slugify } from '../../../helpers/universal/slug.js'; + +describe('slugify', () => { + it('lowercases and hyphenates a basic string', () => { + assert.equal(slugify('Hello World'), 'hello-world'); + }); + + it('handles multiple spaces', () => { + assert.equal(slugify('foo bar'), 'foo-bar'); + }); + + it('strips leading and trailing whitespace', () => { + assert.equal(slugify(' hello '), 'hello'); + }); + + it('preserves accented characters by default', () => { + const result = slugify('Ça va'); + assert.match(result, /^[a-z-]+$/i.test(result) ? /.*/ : /ça|ca/); + }); + + it('strips accents when stripAccents is true', () => { + assert.equal(slugify('Ça va', { stripAccents: true }), 'ca-va'); + }); + + it('strips accents from more complex strings', () => { + assert.equal(slugify('Héllo Wörld', { stripAccents: true }), 'hello-world'); + }); + + it('forwards options to sluggo', () => { + // separator option is passed through to sluggo + const result = slugify('Hello World', { separator: '_' }); + assert.equal(result, 'hello_world'); + }); +}); diff --git a/packages/apostrophe-astro/test/helpers/universal/styles.test.js b/packages/apostrophe-astro/test/helpers/universal/styles.test.js new file mode 100644 index 0000000000..c44f52b886 --- /dev/null +++ b/packages/apostrophe-astro/test/helpers/universal/styles.test.js @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { + stylesElements, + stylesAttributes +} from '../../../helpers/universal/styles.js'; + +describe('stylesElements', () => { + it('returns null when widget has no styles', () => { + assert.equal(stylesElements({}), null); + }); + + it('returns null when _options has no aposStylesElements', () => { + assert.equal(stylesElements({ _options: {} }), null); + }); + + it('returns the HTML string when present', () => { + const widget = { _options: { aposStylesElements: '' } }; + assert.equal(stylesElements(widget), ''); + }); +}); + +describe('stylesAttributes', () => { + it('returns empty object when widget has no styles and no additional attrs', () => { + const attrs = stylesAttributes({}); + assert.deepEqual(attrs, {}); + }); + + it('returns widget styles attributes', () => { + const widget = { _options: { aposStylesAttributes: { class: 'bg-dark', 'data-theme': 'dark' } } }; + const attrs = stylesAttributes(widget); + assert.equal(attrs.class, 'bg-dark'); + assert.equal(attrs['data-theme'], 'dark'); + }); + + it('merges and deduplicates classes', () => { + const widget = { _options: { aposStylesAttributes: { class: 'foo bar' } } }; + const attrs = stylesAttributes(widget, { class: 'bar baz' }); + const classes = attrs.class.split(' '); + assert.ok(classes.includes('foo')); + assert.ok(classes.includes('bar')); + assert.ok(classes.includes('baz')); + assert.equal(classes.filter(c => c === 'bar').length, 1, 'bar should not be duplicated'); + }); + + it('merges additional class as array', () => { + const widget = { _options: { aposStylesAttributes: { class: 'foo' } } }; + const attrs = stylesAttributes(widget, { class: [ 'bar', 'baz' ] }); + const classes = attrs.class.split(' '); + assert.ok(classes.includes('foo')); + assert.ok(classes.includes('bar')); + assert.ok(classes.includes('baz')); + }); + + it('concatenates style strings', () => { + const widget = { _options: { aposStylesAttributes: { style: 'color:red' } } }; + const attrs = stylesAttributes(widget, { style: 'font-size:16px' }); + assert.match(attrs.style, /color:red/); + assert.match(attrs.style, /font-size:16px/); + }); + + it('passes through other additional attributes', () => { + const attrs = stylesAttributes({}, { 'data-id': '123', 'aria-label': 'test' }); + assert.equal(attrs['data-id'], '123'); + assert.equal(attrs['aria-label'], 'test'); + }); + + it('omits additional attributes with null or undefined values', () => { + const attrs = stylesAttributes({}, { 'data-id': null, 'aria-label': undefined }); + assert.ok(!('data-id' in attrs)); + assert.ok(!('aria-label' in attrs)); + }); +}); diff --git a/packages/apostrophe-astro/test/helpers/universal/url.test.js b/packages/apostrophe-astro/test/helpers/universal/url.test.js new file mode 100644 index 0000000000..6a504581d5 --- /dev/null +++ b/packages/apostrophe-astro/test/helpers/universal/url.test.js @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { + getFilterBaseUrl, + buildPageUrl, + aposSetQueryParameter +} from '../../../helpers/universal/url.js'; + +describe('getFilterBaseUrl', () => { + it('returns page._url when no filters are active', () => { + const aposData = { page: { _url: '/articles' }, filters: [] }; + assert.equal(getFilterBaseUrl(aposData), '/articles'); + }); + + it('returns page._url when filters have no active choice', () => { + const aposData = { + page: { _url: '/articles' }, + filters: [ { choices: [ { active: false, _url: '/articles/foo' } ] } ] + }; + assert.equal(getFilterBaseUrl(aposData), '/articles'); + }); + + it('returns the active filter choice _url', () => { + const aposData = { + page: { _url: '/articles' }, + filters: [ + { + choices: [ + { active: false, _url: '/articles/news' }, + { active: true, _url: '/articles/insights' } + ] + } + ] + }; + assert.equal(getFilterBaseUrl(aposData), '/articles/insights'); + }); + + it('falls back to / when page has no _url', () => { + assert.equal(getFilterBaseUrl({ page: {} }), '/'); + }); +}); + +describe('buildPageUrl', () => { + const aposData = { page: { _url: '/articles' } }; + + it('returns base URL for page 1', () => { + assert.equal(buildPageUrl(aposData, 1), '/articles'); + }); + + it('appends query parameter in dynamic mode', () => { + assert.equal(buildPageUrl(aposData, 2), '/articles?page=2'); + }); + + it('appends path segment in static mode', () => { + const data = { ...aposData, staticUrls: true }; + assert.equal(buildPageUrl(data, 2), '/articles/page/2'); + }); + + it('preserves existing query params in dynamic mode', () => { + const data = { page: { _url: '/articles?categories=insights' } }; + const result = buildPageUrl(data, 3); + assert.match(result, /page=3/); + assert.match(result, /categories=insights/); + }); +}); + +describe('aposSetQueryParameter', () => { + const base = new URL('http://localhost/articles'); + + it('sets a new parameter', () => { + const result = aposSetQueryParameter(base, 'page', '2'); + assert.equal(result.searchParams.get('page'), '2'); + }); + + it('updates an existing parameter', () => { + const url = new URL('http://localhost/articles?page=1'); + const result = aposSetQueryParameter(url, 'page', '3'); + assert.equal(result.searchParams.get('page'), '3'); + }); + + it('removes the parameter when value is null', () => { + const url = new URL('http://localhost/articles?page=2'); + const result = aposSetQueryParameter(url, 'page', null); + assert.equal(result.searchParams.get('page'), null); + }); + + it('removes the parameter when value is empty string', () => { + const url = new URL('http://localhost/articles?page=2'); + const result = aposSetQueryParameter(url, 'page', ''); + assert.equal(result.searchParams.get('page'), null); + }); + + it('always strips internal Apostrophe parameters', () => { + const url = new URL('http://localhost/articles?aposRefresh=1&aposMode=edit&aposEdit=1'); + const result = aposSetQueryParameter(url, 'page', '2'); + assert.equal(result.searchParams.get('aposRefresh'), null); + assert.equal(result.searchParams.get('aposMode'), null); + assert.equal(result.searchParams.get('aposEdit'), null); + }); +}); diff --git a/packages/apostrophe-astro/test/lib/aposRequest.test.js b/packages/apostrophe-astro/test/lib/aposRequest.test.js new file mode 100644 index 0000000000..96bbe949c5 --- /dev/null +++ b/packages/apostrophe-astro/test/lib/aposRequest.test.js @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import esmock from 'esmock'; + +const mockConfig = { + aposHost: 'http://localhost:3000', + aposPrefix: '', + staticBuild: null +}; + +async function loadAposRequest(configOverrides = {}, env = {}) { + const savedEnv = {}; + for (const [ key, value ] of Object.entries(env)) { + savedEnv[key] = process.env[key]; + process.env[key] = value; + } + try { + const mod = await esmock('../../lib/aposRequest.js', { + 'apostrophe-astro-config/config': { + default: { ...mockConfig, ...configOverrides } + } + }); + return mod; + } finally { + for (const [ key, value ] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +describe('aposRequest', () => { + const key = 'test-secret-key'; + + beforeEach(() => { + process.env.APOS_EXTERNAL_FRONT_KEY = key; + }); + + afterEach(() => { + delete process.env.APOS_EXTERNAL_FRONT_KEY; + delete process.env.APOS_ASTRO_STATIC_BUILD; + }); + + it('throws when APOS_EXTERNAL_FRONT_KEY is not set', async () => { + delete process.env.APOS_EXTERNAL_FRONT_KEY; + const { default: aposRequest } = await loadAposRequest(); + assert.throws( + () => aposRequest(new Request('http://localhost:4321/page')), + /APOS_EXTERNAL_FRONT_KEY/ + ); + }); + + it('sets required auth headers in SSR mode', async () => { + const { default: aposRequest } = await loadAposRequest(); + const req = new Request('http://localhost:4321/page', { + headers: { cookie: 'session=abc' } + }); + const result = aposRequest(req); + assert.equal(result.headers.get('x-requested-with'), 'AposExternalFront'); + assert.equal(result.headers.get('apos-external-front-key'), key); + }); + + it('forwards user headers in SSR mode', async () => { + const { default: aposRequest } = await loadAposRequest(); + const req = new Request('http://localhost:4321/page', { + headers: { cookie: 'session=abc', 'accept-language': 'fr' } + }); + const result = aposRequest(req); + assert.equal(result.headers.get('cookie'), 'session=abc'); + assert.equal(result.headers.get('accept-language'), 'fr'); + }); + + it('uses URL only in static build mode (env var)', async () => { + process.env.APOS_ASTRO_STATIC_BUILD = '1'; + const { default: aposRequest } = await loadAposRequest(); + const req = new Request('http://localhost:4321/page', { + headers: { cookie: 'session=abc' } + }); + const result = aposRequest(req); + // Should not forward the user cookie + assert.equal(result.headers.get('cookie'), null); + // Should set static base URL header + assert.equal(result.headers.get('x-apos-static-base-url'), '1'); + }); + + it('uses URL only in static build mode (config)', async () => { + const { default: aposRequest } = await loadAposRequest({ staticBuild: { attachments: true } }); + const req = new Request('http://localhost:4321/page', { + headers: { cookie: 'session=abc' } + }); + const result = aposRequest(req); + assert.equal(result.headers.get('cookie'), null); + assert.equal(result.headers.get('x-apos-static-base-url'), '1'); + }); + + it('detects prerendered Astro request by own header getter', async () => { + const { isAstroPrerenderedRequest } = await loadAposRequest(); + const fakeReq = {}; + Object.defineProperty(fakeReq, 'headers', { get: () => new Headers(), configurable: true }); + assert.equal(isAstroPrerenderedRequest(fakeReq), true); + }); + + it('returns false for a plain Request object', async () => { + const { isAstroPrerenderedRequest } = await loadAposRequest(); + const req = new Request('http://localhost/'); + assert.equal(isAstroPrerenderedRequest(req), false); + }); + + it('returns false for a string', async () => { + const { isAstroPrerenderedRequest } = await loadAposRequest(); + assert.equal(isAstroPrerenderedRequest('http://localhost/'), false); + }); +}); diff --git a/packages/apostrophe-astro/test/lib/aposResponse.test.js b/packages/apostrophe-astro/test/lib/aposResponse.test.js new file mode 100644 index 0000000000..3125b5694c --- /dev/null +++ b/packages/apostrophe-astro/test/lib/aposResponse.test.js @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import zlib from 'node:zlib'; +import { promisify } from 'node:util'; +import esmock from 'esmock'; + +const gzipAsync = promisify(zlib.gzip); + +const mockConfig = { + aposHost: 'http://localhost:3000', + aposPrefix: '', + staticBuild: null, + excludeRequestHeaders: [] +}; + +// Build a minimal undici-like response body from a string or Buffer. +// Returns a Uint8Array (valid Response body type) with extra methods +// attached so it works both as a direct Response body (no-compression +// path) and as something with .arrayBuffer()/.dump() (compressed path). +function makeBody(content) { + const buf = Buffer.isBuffer(content) ? content : Buffer.from(content); + let dumped = false; + const body = new Uint8Array(buf); + body.arrayBuffer = async () => buf; + body.dump = async () => { dumped = true; }; + body.wasDumped = () => dumped; + return body; +} + +async function loadAposResponse(configOverrides = {}, mockRequest) { + const undiciRequest = mockRequest || (async () => ({ + statusCode: 200, + headers: { 'content-type': 'application/json' }, + body: makeBody('{"ok":true}') + })); + + return esmock('../../lib/aposResponse.js', { + 'apostrophe-astro-config/config': { + default: { ...mockConfig, ...configOverrides } + }, + 'undici': { request: undiciRequest } + }); +} + +function makeRequest(url = 'http://localhost:4321/page', headers = {}) { + return new Request(url, { headers }); +} + +describe('aposResponse', () => { + describe('header stripping', () => { + it('strips Connection header before forwarding', async () => { + let capturedHeaders; + const { default: aposResponse } = await loadAposResponse({}, async (url, opts) => { + capturedHeaders = opts.headers; + return { statusCode: 200, headers: {}, body: makeBody('{}') }; + }); + + const req = makeRequest('http://localhost:4321/page', { connection: 'keep-alive' }); + await aposResponse(req); + assert.ok(!('connection' in capturedHeaders), 'Connection header should be stripped'); + }); + + it('strips Upgrade header before forwarding', async () => { + let capturedHeaders; + const { default: aposResponse } = await loadAposResponse({}, async (url, opts) => { + capturedHeaders = opts.headers; + return { statusCode: 200, headers: {}, body: makeBody('{}') }; + }); + + const req = makeRequest('http://localhost:4321/page', { + connection: 'Upgrade', + upgrade: 'websocket' + }); + await aposResponse(req); + assert.ok(!('upgrade' in capturedHeaders), 'Upgrade header should be stripped'); + assert.ok(!('connection' in capturedHeaders), 'Connection header should be stripped'); + }); + + it('strips headers listed in excludeRequestHeaders config', async () => { + let capturedHeaders; + const { default: aposResponse } = await loadAposResponse( + { excludeRequestHeaders: [ 'X-Internal-Token' ] }, + async (url, opts) => { + capturedHeaders = opts.headers; + return { statusCode: 200, headers: {}, body: makeBody('{}') }; + } + ); + + const req = makeRequest('http://localhost:4321/page', { 'x-internal-token': 'secret' }); + await aposResponse(req); + assert.ok(!('x-internal-token' in capturedHeaders)); + }); + + it('forwards unblocked headers', async () => { + let capturedHeaders; + const { default: aposResponse } = await loadAposResponse({}, async (url, opts) => { + capturedHeaders = opts.headers; + return { statusCode: 200, headers: {}, body: makeBody('{}') }; + }); + + const req = makeRequest('http://localhost:4321/page', { + cookie: 'session=abc', + 'accept-language': 'fr' + }); + await aposResponse(req); + assert.equal(capturedHeaders['cookie'], 'session=abc'); + assert.equal(capturedHeaders['accept-language'], 'fr'); + }); + }); + + describe('URL construction', () => { + it('routes to the configured aposHost', async () => { + let capturedUrl; + const { default: aposResponse } = await loadAposResponse({}, async (url, opts) => { + capturedUrl = url; + return { statusCode: 200, headers: {}, body: makeBody('{}') }; + }); + + await aposResponse(makeRequest('http://localhost:4321/some/page')); + assert.ok(capturedUrl.startsWith('http://localhost:3000'), `Expected localhost:3000, got ${capturedUrl}`); + }); + + it('preserves the request path and query string', async () => { + let capturedUrl; + const { default: aposResponse } = await loadAposResponse({}, async (url, opts) => { + capturedUrl = url; + return { statusCode: 200, headers: {}, body: makeBody('{}') }; + }); + + await aposResponse(makeRequest('http://localhost:4321/page?foo=bar')); + assert.ok(capturedUrl.includes('/page?foo=bar'), `URL should include path+query, got ${capturedUrl}`); + }); + }); + + describe('invalid Host header', () => { + it('returns 400 when Host contains a slash', async () => { + const { default: aposResponse } = await loadAposResponse(); + const req = makeRequest('http://localhost:4321/page', { host: 'localhost:4321/malicious' }); + const res = await aposResponse(req); + assert.equal(res.status, 400); + }); + }); + + describe('bodyless status codes', () => { + for (const status of [ 204, 304 ]) { + it(`returns null body for ${status}`, async () => { + const { default: aposResponse } = await loadAposResponse({}, async () => ({ + statusCode: status, + headers: {}, + body: makeBody('') + })); + const res = await aposResponse(makeRequest()); + assert.equal(res.status, status); + assert.equal(res.body, null); + }); + } + }); + + describe('normal response', () => { + it('returns the response body for 200', async () => { + const { default: aposResponse } = await loadAposResponse({}, async () => ({ + statusCode: 200, + headers: { 'content-type': 'application/json' }, + body: makeBody('{"page":"home"}') + })); + const res = await aposResponse(makeRequest()); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.page, 'home'); + }); + + it('passes response headers through', async () => { + const { default: aposResponse } = await loadAposResponse({}, async () => ({ + statusCode: 200, + headers: { 'x-custom': 'value' }, + body: makeBody('{}') + })); + const res = await aposResponse(makeRequest()); + assert.equal(res.headers.get('x-custom'), 'value'); + }); + }); + + describe('decompression', () => { + it('decompresses gzip-encoded responses', async () => { + const compressed = await gzipAsync(Buffer.from('{"compressed":true}')); + const { default: aposResponse } = await loadAposResponse({}, async () => ({ + statusCode: 200, + headers: { 'content-encoding': 'gzip', 'content-type': 'application/json' }, + body: { + arrayBuffer: async () => compressed + } + })); + const res = await aposResponse(makeRequest()); + assert.equal(res.headers.get('content-encoding'), null); + const data = await res.json(); + assert.equal(data.compressed, true); + }); + }); + + describe('error handling', () => { + it('returns a 500 text response when undici throws', async () => { + const { default: aposResponse } = await loadAposResponse({}, async () => { + throw new Error('connection refused'); + }); + const res = await aposResponse(makeRequest()); + assert.equal(res.status, 500); + const text = await res.text(); + assert.match(text, /connection refused/); + }); + }); +}); diff --git a/packages/apostrophe-astro/test/setup.js b/packages/apostrophe-astro/test/setup.js new file mode 100644 index 0000000000..ebe87595e2 --- /dev/null +++ b/packages/apostrophe-astro/test/setup.js @@ -0,0 +1,41 @@ +/** + * Mocha setup — runs before all specs. + * + * Creates stub ESM packages for modules that only exist at runtime + * inside Vite (generated by vite-plugin-apostrophe-generated-config). + * Without these stubs Node can't resolve the specifiers and esmock + * can't intercept them. + * + * These stubs provide safe no-op defaults; individual tests override + * them via esmock as needed. + */ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(fileURLToPath(new URL('.', import.meta.url)), '..'); + +await mkdir(join(root, 'node_modules', 'apostrophe-astro-config'), { recursive: true }); + +await writeFile( + join(root, 'node_modules', 'apostrophe-astro-config', 'package.json'), + JSON.stringify({ + name: 'apostrophe-astro-config', + type: 'module', + exports: { + './config': './config.js', + './doctypes': './doctypes.js' + } + }, null, 2) +); + +await writeFile( + join(root, 'node_modules', 'apostrophe-astro-config', 'config.js'), + // Default values — overridden per test via esmock + 'export default { aposHost: "http://localhost:3000", aposPrefix: "", staticBuild: null, excludeRequestHeaders: [] };\n' +); + +await writeFile( + join(root, 'node_modules', 'apostrophe-astro-config', 'doctypes.js'), + 'export const onBeforeWidgetRenderHook = null;\nexport default {};\n' +); From ffdffdce2bef980a2b4794729d8b5f559c3487e8 Mon Sep 17 00:00:00 2001 From: Dipanshu singh <161134993+Dipanshusinghh@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:46:45 +0530 Subject: [PATCH 16/39] Fix raw-text sanitization bypass vulnerability and add regression tests (#5432) --- packages/sanitize-html/index.js | 4 +-- packages/sanitize-html/test/test.js | 56 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/sanitize-html/index.js b/packages/sanitize-html/index.js index 6a67683868..b5da859f7e 100644 --- a/packages/sanitize-html/index.js +++ b/packages/sanitize-html/index.js @@ -566,13 +566,13 @@ function sanitizeHtml(html, options, _recursing) { if (options.disallowedTagsMode === 'completelyDiscard' && !tagAllowed(tag)) { text = ''; - } else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && ((tag === 'script') || (tag === 'style'))) { + } else if (tag && tagAllowed(tag) && (options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && ((tag === 'script') || (tag === 'style'))) { // htmlparser2 gives us these as-is. Escaping them ruins the content. Allowing // script tags is, by definition, game over for XSS protection, so if that's // your concern, don't allow them. The same is essentially true for style tags // which have their own collection of XSS vectors. result += text; - } else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (tag === 'textarea' || tag === 'xmp')) { + } else if (tag && tagAllowed(tag) && (options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (tag === 'textarea' || tag === 'xmp')) { // htmlparser2 treats ', { + nonTextTags: [] + }), + '<script>alert(1)</script>' + ); + }); + + it('should escape raw-text inner content when style tag is disallowed and discarded', function() { + assert.strictEqual( + sanitizeHtml('', { + nonTextTags: [] + }), + '<a onload=alert(1)>' + ); + }); + + it('should handle malformed or unclosed raw-text tags correctly', function() { + assert.strictEqual( + sanitizeHtml('<script>alert(1)', { + nonTextTags: ['script', 'style'] + }), + '&lt;script&gt;alert(1)' + ); + }); + + it('should handle sibling raw-text elements correctly without leaking states', function() { + assert.strictEqual( + sanitizeHtml('<noembed><script>alert(1)</script></noembed><noscript><style>body{}</style></noscript>', { + nonTextTags: [] + }), + 'alert(1)body{}' + ); + }); + }); }); From c21d5fb00801778a41698d8c31d987d1ed657a90 Mon Sep 17 00:00:00 2001 From: Tom Boutell <tom@apostrophecms.com> Date: Thu, 4 Jun 2026 08:51:31 -0400 Subject: [PATCH 17/39] changeset for singh contribution (#5442) --- .changeset/new-doors-turn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/new-doors-turn.md diff --git a/.changeset/new-doors-turn.md b/.changeset/new-doors-turn.md new file mode 100644 index 0000000000..b7aa9e76ab --- /dev/null +++ b/.changeset/new-doors-turn.md @@ -0,0 +1,5 @@ +--- +"sanitize-html": patch +--- + +Address a potential vulnerability when nonTextTags is configured in a nonstandard way. While it is never a good idea to remove known non-text tags from the standard list e.g. script, styles, etc., this change ensures that doing so does not result in nested tags being passed through without sanitization when they are not expressly allowed. (ApostropheCMS would never trigger this situation.) Thanks to [Dipanshu singh](https://github.com/Dipanshusinghh) for pointing out the issue and contributing the fix. From 62d710de0219ac7308b41204b12442230f67dc13 Mon Sep 17 00:00:00 2001 From: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:31:33 +0300 Subject: [PATCH 18/39] Fix relationship select scrolling issue (#5445) * Fix relationship select scrolling issue * Prevent same scrolling bugs to appear in media manager --- .changeset/smart-kids-rest.md | 5 +++++ .../image/ui/apos/components/AposMediaUploader.vue | 3 +++ .../@apostrophecms/ui/ui/apos/scss/global/_inputs.scss | 2 ++ 3 files changed, 10 insertions(+) create mode 100644 .changeset/smart-kids-rest.md diff --git a/.changeset/smart-kids-rest.md b/.changeset/smart-kids-rest.md new file mode 100644 index 0000000000..ce014a1952 --- /dev/null +++ b/.changeset/smart-kids-rest.md @@ -0,0 +1,5 @@ +--- +"apostrophe": patch +--- + +Selecting an item in a relationship "browse" dialog no longer scrolls the title and Cancel/Select buttons out of view when the item is far down the list. diff --git a/packages/apostrophe/modules/@apostrophecms/image/ui/apos/components/AposMediaUploader.vue b/packages/apostrophe/modules/@apostrophecms/image/ui/apos/components/AposMediaUploader.vue index 9ba38df9e4..4041ccd6ea 100644 --- a/packages/apostrophe/modules/@apostrophecms/image/ui/apos/components/AposMediaUploader.vue +++ b/packages/apostrophe/modules/@apostrophecms/image/ui/apos/components/AposMediaUploader.vue @@ -252,6 +252,9 @@ export default { @include apos-transition(); & { + // Contain the visually hidden (`.apos-sr-only`, position: absolute) + // file input so focusing it does not scroll a distant ancestor. + position: relative; display: flex; box-sizing: border-box; align-items: center; diff --git a/packages/apostrophe/modules/@apostrophecms/ui/ui/apos/scss/global/_inputs.scss b/packages/apostrophe/modules/@apostrophecms/ui/ui/apos/scss/global/_inputs.scss index 60dcd1b43c..36d857504c 100644 --- a/packages/apostrophe/modules/@apostrophecms/ui/ui/apos/scss/global/_inputs.scss +++ b/packages/apostrophe/modules/@apostrophecms/ui/ui/apos/scss/global/_inputs.scss @@ -372,6 +372,8 @@ @include type-base; & { + // Do not remove - it fixes unintended `sr-only` side effect. + position: relative; display: flex; align-items: center; color: var(--a-base-2); From f1dd138f5fa59c648ebde2dfd210afdb52b1cfe9 Mon Sep 17 00:00:00 2001 From: Tom Boutell <tom@apostrophecms.com> Date: Fri, 5 Jun 2026 09:34:43 -0400 Subject: [PATCH 19/39] jsx changeset (#5446) --- .changeset/violet-windows-draw.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/violet-windows-draw.md diff --git a/.changeset/violet-windows-draw.md b/.changeset/violet-windows-draw.md new file mode 100644 index 0000000000..755e3cde7d --- /dev/null +++ b/.changeset/violet-windows-draw.md @@ -0,0 +1,5 @@ +--- +"apostrophe": minor +--- + +JSX support for templates within ApostropheCMS. JSX is now co-equal with Nunjucks, with a gradual migration strategy. Anyone who is familiar with React will be very comfortable writing JSX templates, which also offer a superior debugging experience, and templates can be migrated gradually. JSX is a great option for those who don't wish to create parallel Astro and ApostropheCMS projects, but still prefer a modern syntax. For more information, see the new [JSX templates guide](https://apostrophecms.com/docs/guide/jsx-templates.html). From 3bc03d8b1a605a628d99ecc639685f5c60609d94 Mon Sep 17 00:00:00 2001 From: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:51:25 +0300 Subject: [PATCH 20/39] Ensure install of the project root for astro projects (#5449) --- .../src/core/steps/install.js | 10 +++++-- .../test/core/install.test.js | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/create-apostrophe/src/core/steps/install.js b/packages/create-apostrophe/src/core/steps/install.js index b860e49a53..4539916a21 100644 --- a/packages/create-apostrophe/src/core/steps/install.js +++ b/packages/create-apostrophe/src/core/steps/install.js @@ -1,6 +1,9 @@ // Step: install dependencies with npm. Standalone installs once in the app -// root; an external-frontend project installs in backend/ and frontend/. -// A non-npm manager is rejected up front. Failure → 'dependency_install'. +// root; an external-frontend project installs in backend/ and frontend/, and +// — if the project root ships its own package.json — also at the root (some +// Astro kits put orchestration deps like `concurrently` there to power a +// single `npm run dev`). A non-npm manager is rejected up front. Failure → +// 'dependency_install'. import { join } from 'node:path'; import { existsSync } from 'node:fs'; @@ -41,6 +44,9 @@ export async function install( if (existsSync(frontendDir)) { dirs.push(frontendDir); } + if (existsSync(join(projectDir, 'package.json'))) { + dirs.push(projectDir); + } } for (const cwd of dirs) { diff --git a/packages/create-apostrophe/test/core/install.test.js b/packages/create-apostrophe/test/core/install.test.js index 7204634ab6..7c84d8b78c 100644 --- a/packages/create-apostrophe/test/core/install.test.js +++ b/packages/create-apostrophe/test/core/install.test.js @@ -88,6 +88,36 @@ describe('core/steps/install', function () { ]); }); + it('external frontend + root package.json: backend → frontend → root', async function () { + // Newer Astro kits ship a root package.json with orchestration deps + // (concurrently) so a single `npm run dev` runs both apps. Without + // installing the root, `npm run dev` fails with "concurrently: not found". + const projectDir = join(dir, 'proj'); + const appRoot = join(projectDir, 'backend'); + mkdirSync(appRoot, { recursive: true }); + mkdirSync(join(projectDir, 'frontend'), { recursive: true }); + writeFileSync(join(projectDir, 'package.json'), '{}'); + + const calls = []; + await install( + { + projectDir, + appRoot, + frontend: 'astro', + packageManager: 'npm' + }, + { run: fakeRunFactory(calls) } + ); + + // Root install runs LAST so its (potentially redundant) recursion into + // workspaces hits an already-warm cache. + assert.deepEqual(calls.map((c) => [ c.command, c.cwd ]), [ + [ 'npm', appRoot ], + [ 'npm', join(projectDir, 'frontend') ], + [ 'npm', projectDir ] + ]); + }); + it('unknown pm runs npm, reports \'unknown\'', async function () { const projectDir = join(dir, 'proj'); mkdirSync(projectDir, { recursive: true }); From 889910338c64a296523e7b32ab37af45e1c793b9 Mon Sep 17 00:00:00 2001 From: Tom Boutell <tom@apostrophecms.com> Date: Mon, 8 Jun 2026 14:34:14 -0400 Subject: [PATCH 21/39] test node 26 (#5450) * test node 26 * support node 26 by bumping the better-sqlite3 version * node 22 requirement --- .github/workflows/monorepo.yml | 2 +- packages/create-apostrophe/README.md | 3 +-- packages/create-apostrophe/bin/create-apostrophe.js | 6 +++--- packages/create-apostrophe/package.json | 2 +- packages/db-connect/package.json | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/monorepo.yml b/.github/workflows/monorepo.yml index 4276a83fe0..8cf21c4887 100644 --- a/.github/workflows/monorepo.yml +++ b/.github/workflows/monorepo.yml @@ -4,7 +4,7 @@ permissions: env: # Define supported runtime versions for matrix expansion once for the workflow. - NODE_VERSIONS_JSON: "[20,22,24]" + NODE_VERSIONS_JSON: "[22,24,26]" MONGODB_VERSIONS_JSON: '["7","8"]' REDIS_VERSION: "7" POSTGRES_VERSION: "16" diff --git a/packages/create-apostrophe/README.md b/packages/create-apostrophe/README.md index c563a35c4d..73bb0e003b 100644 --- a/packages/create-apostrophe/README.md +++ b/packages/create-apostrophe/README.md @@ -28,8 +28,7 @@ npm create apostrophe@latest -- --help > Everything after `--` is forwarded to the installer; npm swallows args without it. > Run `-- --help` any time for the full flag list. -Requires **Node 20+** and **npm** (pnpm and yarn aren't supported yet - running -under either is rejected up front). +Requires **Node 22+** and **npm** (pnpm and yarn aren't by this CLI yet, but they work fine with apostrophe). ## Architecture diff --git a/packages/create-apostrophe/bin/create-apostrophe.js b/packages/create-apostrophe/bin/create-apostrophe.js index e86700a3a2..6d6056c2c2 100755 --- a/packages/create-apostrophe/bin/create-apostrophe.js +++ b/packages/create-apostrophe/bin/create-apostrophe.js @@ -5,11 +5,11 @@ const nodeVersion = process && process.versions && process.versions.node; const major = nodeVersion ? parseInt(nodeVersion.split('.')[0], 10) : NaN; -if (Number.isFinite(major) && major < 20) { +if (Number.isFinite(major) && major < 22) { process.stderr.write( - 'Node.js 20 or newer is required. ' + + 'Node.js 22 or newer is required. ' + 'You are running Node ' + nodeVersion + '.\n' + - 'Please upgrade Node (https://nodejs.org) and try again.\n' + 'Please upgrade Node (https://nodejs.org) to a current LTS release and try again.\n' ); process.exit(1); } diff --git a/packages/create-apostrophe/package.json b/packages/create-apostrophe/package.json index 107b907b81..4234de6356 100644 --- a/packages/create-apostrophe/package.json +++ b/packages/create-apostrophe/package.json @@ -4,7 +4,7 @@ "description": "Guided installer for ApostropheCMS — npm create apostrophe@latest", "type": "module", "engines": { - "node": ">=20" + "node": ">=22" }, "bin": { "create-apostrophe": "bin/create-apostrophe.js" diff --git a/packages/db-connect/package.json b/packages/db-connect/package.json index 37150cddfe..fcfeaa4de9 100644 --- a/packages/db-connect/package.json +++ b/packages/db-connect/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@apostrophecms/emulate-mongo-3-driver": "workspace:^", - "better-sqlite3": "^11.0.0", + "better-sqlite3": "^12.10.0", "pg": "^8.11.3" } } From 4e1be7a8c7934d21868b409e6c24dd440a39520b Mon Sep 17 00:00:00 2001 From: Robert Means <robert@apostrophecms.com> Date: Tue, 9 Jun 2026 10:49:09 -0400 Subject: [PATCH 22/39] Add link for telemetry policy (#5455) --- packages/create-apostrophe/src/ui/links.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-apostrophe/src/ui/links.js b/packages/create-apostrophe/src/ui/links.js index bcbd2ff7aa..2839d0255f 100644 --- a/packages/create-apostrophe/src/ui/links.js +++ b/packages/create-apostrophe/src/ui/links.js @@ -5,7 +5,7 @@ // without each call site remembering to do it. const LINKS = Object.freeze({ - telemetryPolicy: '[link to telemetry policy]', + telemetryPolicy: 'https://apostrophecms.com/telemetry-policy?utm_source=cli', docs: 'https://docs.apostrophecms.com', discord: 'https://discord.gg/apostrophe', demoSite: 'https://demo.apostrophecms.com', From ff6cbb6baba6852e8feb497c6678598ea49cfb27 Mon Sep 17 00:00:00 2001 From: Tom Boutell <tom@apostrophecms.com> Date: Tue, 9 Jun 2026 12:27:14 -0400 Subject: [PATCH 23/39] remove absent options (#5456) --- packages/cli/README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 1a61f5fe6e..fd3cf0ba10 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -27,15 +27,6 @@ apos create <shortname-without-spaces> This will create a local copy of the [ApostropheCMS public demo](https://github.com/apostrophecms/public-demo). -### options - -#### `--starter` - -Run `create` with a `--starter` flag to start from a Github repository other than the standard starters. For example, `apos create <shortname-without-spaces> --starter=https://github.com/apostrophecms/apostrophe-open-museum.git` would create a project using the [Open Museum](https://github.com/apostrophecms/apostrophe-open-museum) demo. The `--starter` flag also accepts shortened names for any of the [existing starter kits](https://github.com/orgs/apostrophecms/repositories?q=starter-kit&type=all) that consists of the name of the repo with the `starter-kit-` prefix removed. For example, `apos create <shortname-without-spaces> --starter=ecommerce` for the `starter-kit-ecommerce` repo. Finally, if you are using a personal or organizational repo, you can prefix your repo with it's location followed by the name to automatically add `https://github.com/`. For example, `apos create <shortname-without-spaces> --starter=mycoolcompany/my-starter`. - -#### `--mongodb-uri` -If you are not using a locally hosted MongoDB server, you can provide a connection string with the `--mongodb-uri` flag. For the standard Atlas connection string, you will need to add quotes around the connection string due to the query parameters. This allows for the creation of an admin user during project creation. **Note**: this will not add your connection string to the project. It needs to be included through the `APOS_MONGODB_URI` environment variable, potentially through a `.env` file. - ### Astro projects Hybrid ApostropheCMS + Astro projects are automatically detected by the presence of a `backend/` directory. When detected, the CLI will: From 043c215d8060f5f29a3b2e71b14fc051353e7d34 Mon Sep 17 00:00:00 2001 From: Robert Means <robert@apostrophecms.com> Date: Wed, 10 Jun 2026 05:33:43 -0400 Subject: [PATCH 24/39] Remove consumed 4.30.0 changesets from main (#5454) --- .changeset/a11y-admin-nav.md | 5 ----- .changeset/a11y-context-title.md | 5 ----- .changeset/a11y-locale-switcher.md | 5 ----- .changeset/a11y-recently-edited-icon.md | 5 ----- .changeset/a11y-sr-only.md | 5 ----- .changeset/a11y-tray-aria.md | 5 ----- .changeset/busy-spies-enter.md | 5 ----- .changeset/clean-actors-laugh.md | 5 ----- .changeset/cozy-wombats-burn.md | 5 ----- .changeset/fiery-lights-fetch.md | 5 ----- .changeset/fifty-hornets-follow.md | 7 ------- .changeset/full-symbols-obey.md | 5 ----- .changeset/open-garlics-smile.md | 7 ------- .changeset/pink-buckets-write.md | 7 ------- .changeset/quick-guests-join.md | 5 ----- .changeset/seven-emus-vanish.md | 6 ------ .changeset/solid-boats-send.md | 10 ---------- .changeset/stale-shirts-read.md | 5 ----- .changeset/upset-ties-cough.md | 5 ----- .changeset/wild-forks-fetch.md | 15 --------------- .changeset/wild-lies-film.md | 5 ----- 21 files changed, 127 deletions(-) delete mode 100644 .changeset/a11y-admin-nav.md delete mode 100644 .changeset/a11y-context-title.md delete mode 100644 .changeset/a11y-locale-switcher.md delete mode 100644 .changeset/a11y-recently-edited-icon.md delete mode 100644 .changeset/a11y-sr-only.md delete mode 100644 .changeset/a11y-tray-aria.md delete mode 100644 .changeset/busy-spies-enter.md delete mode 100644 .changeset/clean-actors-laugh.md delete mode 100644 .changeset/cozy-wombats-burn.md delete mode 100644 .changeset/fiery-lights-fetch.md delete mode 100644 .changeset/fifty-hornets-follow.md delete mode 100644 .changeset/full-symbols-obey.md delete mode 100644 .changeset/open-garlics-smile.md delete mode 100644 .changeset/pink-buckets-write.md delete mode 100644 .changeset/quick-guests-join.md delete mode 100644 .changeset/seven-emus-vanish.md delete mode 100644 .changeset/solid-boats-send.md delete mode 100644 .changeset/stale-shirts-read.md delete mode 100644 .changeset/upset-ties-cough.md delete mode 100644 .changeset/wild-forks-fetch.md delete mode 100644 .changeset/wild-lies-film.md diff --git a/.changeset/a11y-admin-nav.md b/.changeset/a11y-admin-nav.md deleted file mode 100644 index e6025193a8..0000000000 --- a/.changeset/a11y-admin-nav.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apostrophe": patch ---- - -Accessibility: corrected ARIA semantics on the top admin navigation bar. diff --git a/.changeset/a11y-context-title.md b/.changeset/a11y-context-title.md deleted file mode 100644 index 344cf6fa7b..0000000000 --- a/.changeset/a11y-context-title.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apostrophe": patch ---- - -Accessibility: improvements to the document context title (admin bar middle group) and the underlying `AposContextMenu` machinery. diff --git a/.changeset/a11y-locale-switcher.md b/.changeset/a11y-locale-switcher.md deleted file mode 100644 index ec687d8c7d..0000000000 --- a/.changeset/a11y-locale-switcher.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apostrophe": patch ---- - -Accessibility: improve the locale switcher (`AposLocalePicker`). diff --git a/.changeset/a11y-recently-edited-icon.md b/.changeset/a11y-recently-edited-icon.md deleted file mode 100644 index 8c6a04567c..0000000000 --- a/.changeset/a11y-recently-edited-icon.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apostrophe": patch ---- - -Accessibility: the Recently Edited Documents tray icon (admin bar) now exposes its action through `aria-label`. diff --git a/.changeset/a11y-sr-only.md b/.changeset/a11y-sr-only.md deleted file mode 100644 index 193a4cf5ec..0000000000 --- a/.changeset/a11y-sr-only.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apostrophe": patch ---- - -Accessibility: fix `.apos-sr-only` so screen-reader-only content is exposed to the accessibility tree. diff --git a/.changeset/a11y-tray-aria.md b/.changeset/a11y-tray-aria.md deleted file mode 100644 index 4f39003b28..0000000000 --- a/.changeset/a11y-tray-aria.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apostrophe": patch ---- - -Accessibility: icon-only context-utility buttons in the admin bar tray (e.g. the global settings cog) now expose their action through `aria-label`. diff --git a/.changeset/busy-spies-enter.md b/.changeset/busy-spies-enter.md deleted file mode 100644 index cc922b7b1c..0000000000 --- a/.changeset/busy-spies-enter.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apostrophe": patch ---- - -Security fix: the password reset request feature will refuse to operate unless the baseUrl option or the APOS_BASE_URL environment variable has been set (note that this is automatic in multisite projects). This fix is necessary to prevent a vulnerability that can be used to convince ApostropheCMS to send emails containing links to other sites. It is only a vulnerability if you have enabled the passwordReset: true option for the login module. Thanks to [SPIDY](https://github.com/Mujahidkhan525) for reporting the issue. diff --git a/.changeset/clean-actors-laugh.md b/.changeset/clean-actors-laugh.md deleted file mode 100644 index 5d78fab358..0000000000 --- a/.changeset/clean-actors-laugh.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apostrophe": patch ---- - -Fix illegal HTML id attribute values generated by the admin UI diff --git a/.changeset/cozy-wombats-burn.md b/.changeset/cozy-wombats-burn.md deleted file mode 100644 index a484ecded0..0000000000 --- a/.changeset/cozy-wombats-burn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@apostrophecms/cli": patch ---- - -Security: bump and clean up dependencies. This closes vulnerabilities in `uuid` and `fast-xml-parser` although they were not used in a sensitive or vulnerable way within ApostropheCMS. This also closes a vulnerability in `shelljs` which ould only be exploited if the developer could be convinced to enter malicious commands as part of their CLI input. diff --git a/.changeset/fiery-lights-fetch.md b/.changeset/fiery-lights-fetch.md deleted file mode 100644 index 3bcf8c1da7..0000000000 --- a/.changeset/fiery-lights-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@apostrophecms/cli": patch ---- - -Security: passwords and starter kit URLs containing intentionally malicious punctuation cannot be used to run arbitrary shell commands. Because the CLI is only used by developers, this would always have been an "own goal" situation, however this does make the CLI more robust for scripted use. Thanks to [Nitro13urn](https://github.com/VadlaReddySai) for reporting the issue. diff --git a/.changeset/fifty-hornets-follow.md b/.changeset/fifty-hornets-follow.md deleted file mode 100644 index d698950dad..0000000000 --- a/.changeset/fifty-hornets-follow.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"apostrophe": patch ---- - -- Removed duplicate <meta charset> tag from `outerLayoutBase.html` -- Standardized charset to utf-8 (the legacy configuration option is now ignored). Per the spec this is the only legal setting, so we classify this as a bug fix -- Altered unused/legacy i18n template helper to return `utf-8`, ensuring backwards compatibility diff --git a/.changeset/full-symbols-obey.md b/.changeset/full-symbols-obey.md deleted file mode 100644 index 903395756e..0000000000 --- a/.changeset/full-symbols-obey.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apostrophe": patch ---- - -Keyboard shortcuts for widget operations (copy, cut, paste, duplicate, remove) no longer block the browser's native clipboard behavior when no widget is focused. Previously, selecting and copying text on a page while logged in was prevented by the admin UI intercepting those shortcuts unconditionally. diff --git a/.changeset/open-garlics-smile.md b/.changeset/open-garlics-smile.md deleted file mode 100644 index 610ece378e..0000000000 --- a/.changeset/open-garlics-smile.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"apostrophe": patch ---- - -Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an -XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be -updated promptly to close this vulnerability. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting the issue. diff --git a/.changeset/pink-buckets-write.md b/.changeset/pink-buckets-write.md deleted file mode 100644 index 9333df426d..0000000000 --- a/.changeset/pink-buckets-write.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"sanitize-html": patch -"apostrophe": patch -"launder": patch ---- - -Security: launder now uses and exports the best available naughtyHref function for detecting malicious URLs. sanitize-html now depends on it, and apostrophe now uses type: 'url' for the link URL field of image widgets, which leverages it. Prior to this fix, it was possible for any user with editing privileges, including a contributor, to trigger arbitrary JavaScript via a javascript: URL in the link URL field of an image widget. A migration has been included to strip any such malicious URLs already present in the database. All users of apostrophe are encouraged to upgrade to get this security fix. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting the issue. diff --git a/.changeset/quick-guests-join.md b/.changeset/quick-guests-join.md deleted file mode 100644 index 8bab7b0384..0000000000 --- a/.changeset/quick-guests-join.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apostrophe": minor ---- - -Fix layout widget not regaining full focus on switching back to Edit content mode. diff --git a/.changeset/seven-emus-vanish.md b/.changeset/seven-emus-vanish.md deleted file mode 100644 index 564a10e231..0000000000 --- a/.changeset/seven-emus-vanish.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@apostrophecms/apostrophe-astro": minor -"apostrophe": minor ---- - -Editors can now control the layout-widget gap through the styles system, both site-wide via a global `layoutGap` preset and per widget via a `gap` styles field. New Layout widget option `className` allows for additional CSS class names to be added to the widget Grid container. diff --git a/.changeset/solid-boats-send.md b/.changeset/solid-boats-send.md deleted file mode 100644 index d0f848873f..0000000000 --- a/.changeset/solid-boats-send.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"apostrophe": patch ---- - -Security: the HTML import feature of the rich text widget no longer permits images to be fetched -from arbitrary hosts. This could be used to probe internal networks, and to exfiltrate images from -internal hosts if their URLs were known. Instead, the `imageImportAllowedHostnames` option of the -`@apostrophecms/rich-text-widget` must be configured to opt into that feature. - -Thanks to [Yiğit Şengezer](https://github.com/yigitsengezer) and [Sainithin0309](https://github.com/Sainithin0309) for reporting this issue. diff --git a/.changeset/stale-shirts-read.md b/.changeset/stale-shirts-read.md deleted file mode 100644 index 271cd86872..0000000000 --- a/.changeset/stale-shirts-read.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apostrophe": patch ---- - -fixes issue where orderable table array items drag the entire floating window diff --git a/.changeset/upset-ties-cough.md b/.changeset/upset-ties-cough.md deleted file mode 100644 index 6795e47545..0000000000 --- a/.changeset/upset-ties-cough.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"sanitize-html": patch ---- - -Security vulnerability: the xmp tag could be used to pass forbidden markup through sanitize-html, even when xmp itself is not explicitly allowed All users of sanitize-html should update immediately. Thanks to [Vincenzo Turturro](https://github.com/sushi-gif) for reporting the vulnerability. diff --git a/.changeset/wild-forks-fetch.md b/.changeset/wild-forks-fetch.md deleted file mode 100644 index a175167488..0000000000 --- a/.changeset/wild-forks-fetch.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"apostrophe": patch ---- - -apostrophe and oembetter have been updated to eliminate a number of services that formerly supported -oembed for the general public, but no longer do so. While there is no security risk today, removing -these ensures that if these domains are ever allowed to lapse, they do not become an XSS -attack vector in the future. - -Because oembed responses are not always iframes, it is important that this list be maintained -over time. In addition, developers always have the option to prune it on their own by setting -the new minimumAllowlist and minimumEndpoints options of the @apostrophecms/oembed module. - -Thanks to [Sainithin0309](https://github.com/Sainithin0309) for pointing out the potential -long-term security concern. diff --git a/.changeset/wild-lies-film.md b/.changeset/wild-lies-film.md deleted file mode 100644 index 3b608070dd..0000000000 --- a/.changeset/wild-lies-film.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@apostrophecms/apostrophe-astro": minor ---- - -Log aposResponse errors server side in the Astro process. Thanks to [Harouna Traore](https://github.com/haroun). From 6fba44201510fed0677e6b675ccbcbcf7228630b Mon Sep 17 00:00:00 2001 From: Tom Boutell <tom@apostrophecms.com> Date: Wed, 10 Jun 2026 08:59:35 -0400 Subject: [PATCH 25/39] cli links that are correct, or will be post publish (#5458) --- packages/create-apostrophe/src/ui/links.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/create-apostrophe/src/ui/links.js b/packages/create-apostrophe/src/ui/links.js index 2839d0255f..9e119897d2 100644 --- a/packages/create-apostrophe/src/ui/links.js +++ b/packages/create-apostrophe/src/ui/links.js @@ -7,9 +7,9 @@ const LINKS = Object.freeze({ telemetryPolicy: 'https://apostrophecms.com/telemetry-policy?utm_source=cli', docs: 'https://docs.apostrophecms.com', - discord: 'https://discord.gg/apostrophe', + discord: 'https://chat.apostrophecms.com', demoSite: 'https://demo.apostrophecms.com', - dbGuide: 'https://apostrophecms.com/guides/choosing-a-db' + dbGuide: 'https://apostrophecms.com/docs/guide/choosing-a-database.html' }); /** Per-kit "get oriented" guides. `*-demo` and `*-demo-data` share a guide. */ From a6daca5ed639e643a4fab200661c6ee6674ba33e Mon Sep 17 00:00:00 2001 From: Tom Boutell <tom@apostrophecms.com> Date: Wed, 10 Jun 2026 09:38:58 -0400 Subject: [PATCH 26/39] release db connect to solve chicken and egg problem in cypress-tools (#5459) --- packages/db-connect/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db-connect/package.json b/packages/db-connect/package.json index fcfeaa4de9..3413e92507 100644 --- a/packages/db-connect/package.json +++ b/packages/db-connect/package.json @@ -1,6 +1,6 @@ { "name": "@apostrophecms/db-connect", - "version": "1.0.0", + "version": "1.0.1", "description": "Database connection library and dump/restore tools for ApostropheCMS", "license": "MIT", "main": "index.js", From 227b638c40cc3412d80a784292234e458d96077d Mon Sep 17 00:00:00 2001 From: Robert Means <robert@apostrophecms.com> Date: Wed, 10 Jun 2026 09:51:07 -0400 Subject: [PATCH 27/39] Corrects documentation links (#5457) * Corrects documentation links * correct `guides` -> `guide` --- packages/create-apostrophe/src/ui/links.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/create-apostrophe/src/ui/links.js b/packages/create-apostrophe/src/ui/links.js index 9e119897d2..43cd409af6 100644 --- a/packages/create-apostrophe/src/ui/links.js +++ b/packages/create-apostrophe/src/ui/links.js @@ -14,12 +14,12 @@ const LINKS = Object.freeze({ /** Per-kit "get oriented" guides. `*-demo` and `*-demo-data` share a guide. */ const KIT_GUIDES = Object.freeze({ - 'apostrophe-astro-essentials': 'https://apostrophecms.com/guides/astro-essentials-overview', - 'apostrophe-astro-demo': 'https://apostrophecms.com/guides/astro-demo-overview', - 'apostrophe-astro-demo-data': 'https://apostrophecms.com/guides/astro-demo-overview', - 'apostrophe-essentials': 'https://apostrophecms.com/guides/apostrophe-standalone-essentials-overview', - 'apostrophe-demo': 'https://apostrophecms.com/guides/apostrophe-demo-overview', - 'apostrophe-demo-data': 'https://apostrophecms.com/guides/apostrophe-demo-overview' + 'apostrophe-astro-essentials': 'https://apostrophecms.com/docs/guide/astro-essentials-overview.html', + 'apostrophe-astro-demo': 'https://apostrophecms.com/docs/guide/astro-demo-overview.html', + 'apostrophe-astro-demo-data': 'https://apostrophecms.com/docs/guide/astro-demo-overview.html', + 'apostrophe-essentials': 'https://apostrophecms.com/docs/guide/apostrophe-standalone-essentials-overview.html', + 'apostrophe-demo': 'https://apostrophecms.com/docs/guides/apostrophe-demo-overview.html', + 'apostrophe-demo-data': 'https://apostrophecms.com/docs/guide/apostrophe-demo-overview.html' }); /** @typedef {keyof typeof LINKS} LinkName */ From 6e6b98c02a19d0794e60861ca9babc92400f854a Mon Sep 17 00:00:00 2001 From: Tom Boutell <tom@apostrophecms.com> Date: Wed, 10 Jun 2026 11:45:37 -0400 Subject: [PATCH 28/39] Merge commit from fork --- .changeset/proud-moons-guard.md | 5 + .../modules/@apostrophecms/util/index.js | 17 +++ packages/apostrophe/test/utils.js | 103 ++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 .changeset/proud-moons-guard.md diff --git a/.changeset/proud-moons-guard.md b/.changeset/proud-moons-guard.md new file mode 100644 index 0000000000..ea5449c194 --- /dev/null +++ b/.changeset/proud-moons-guard.md @@ -0,0 +1,5 @@ +--- +"apostrophe": patch +--- + +Security fix: server-side prototype pollution (CWE-1321) via dot-notation paths. `apos.util.set()` and `apos.util.get()` now refuse to traverse `__proto__`, `constructor` and `prototype` path segments. Previously an authenticated editor could send a PATCH REST API request whose patch operators (for example `$pullAll` with a key of `__proto__.publicApiProjection`) wrote to `Object.prototype`. A polluted `publicApiProjection` defeated the `publicApiCheck()` authorization gate on piece-type REST endpoints for subsequent unauthenticated requests, for the lifetime of the Node.js process. All users should update. Thanks to [tonghuaroot](https://github.com/tonghuaroot), [H3xV0rT3x](https://github.com/H3xV0rT3x), and [5h1kh4r](https://github.com/5h1kh4r) for reporting the vulnerability. diff --git a/packages/apostrophe/modules/@apostrophecms/util/index.js b/packages/apostrophe/modules/@apostrophecms/util/index.js index 38b9e54527..24e4d18df3 100644 --- a/packages/apostrophe/modules/@apostrophecms/util/index.js +++ b/packages/apostrophe/modules/@apostrophecms/util/index.js @@ -35,6 +35,13 @@ const util = require('util'); const { stripIndent } = require('common-tags'); const glob = require('../../../lib/glob.js'); +// Dot-path segments that must never be traversed when walking a +// user-supplied path in `apos.util.get` and `apos.util.set`. Following any +// of these reaches the prototype chain and enables server-side prototype +// pollution (CWE-1321), e.g. a PATCH `$pullAll` key of +// `__proto__.publicApiProjection`. +const unsafePathSegments = new Set([ '__proto__', 'constructor', 'prototype' ]); + module.exports = { options: { alias: 'util', @@ -755,6 +762,10 @@ module.exports = { if (o == null) { return undefined; } + if (unsafePathSegments.has(p)) { + // Never read through the prototype chain (CWE-1321) + return undefined; + } o = o[p]; } } @@ -819,6 +830,12 @@ module.exports = { } } path = path.split('.'); + for (p of path) { + if (unsafePathSegments.has(p)) { + // Refuse to write through the prototype chain (CWE-1321) + throw self.apos.error('invalid', `Unsafe property name "${p}" in dot path`); + } + } for (i = 0; (i < (path.length - 1)); i++) { p = path[i]; o = o[p]; diff --git a/packages/apostrophe/test/utils.js b/packages/apostrophe/test/utils.js index 7de7bcb225..5a59e8c14c 100644 --- a/packages/apostrophe/test/utils.js +++ b/packages/apostrophe/test/utils.js @@ -372,6 +372,109 @@ describe('Utils', async function() { assert(data.shoes[0].size === 8); }); + // Server-Side Prototype Pollution (CWE-1321) regression coverage. + // apos.util.set and apos.util.get traverse user-supplied dot-notation + // paths. A segment of `__proto__`, `constructor` or `prototype` must + // never be followed, or an authenticated editor could write to + // Object.prototype (for example via the $pullAll patch operator) and + // poison authorization checks process-wide. See GHSA-6h5j-32cf-4253. + + it('utils.set must reject a __proto__ segment instead of polluting Object.prototype', function() { + const data = {}; + try { + assert.throws(() => { + apos.util.set(data, '__proto__.polluted', 'yes'); + }, { name: 'invalid' }); + assert.strictEqual({}.polluted, undefined); + assert.strictEqual(data.polluted, undefined); + } finally { + // Belt and suspenders: if the guard ever regresses, do not leak a + // polluted prototype into the rest of the suite. + delete Object.prototype.polluted; + } + }); + + it('utils.set must reject a constructor.prototype segment', function() { + const data = {}; + try { + assert.throws(() => { + apos.util.set(data, 'constructor.prototype.polluted', 'yes'); + }, { name: 'invalid' }); + assert.strictEqual({}.polluted, undefined); + } finally { + delete Object.prototype.polluted; + } + }); + + it('utils.set must reject a trailing __proto__ segment rather than replacing the prototype', function() { + const data = {}; + assert.throws(() => { + apos.util.set(data, '__proto__', { polluted: 'yes' }); + }, { name: 'invalid' }); + assert.strictEqual(Object.getPrototypeOf(data), Object.prototype); + assert.strictEqual(data.polluted, undefined); + }); + + it('utils.set must reject a dangerous segment exposed after an @ reference', function() { + const data = { + items: [ + { + _id: 'abc', + sub: {} + } + ] + }; + try { + assert.throws(() => { + apos.util.set(data, '@abc.__proto__.polluted', 'yes'); + }, { name: 'invalid' }); + assert.strictEqual({}.polluted, undefined); + } finally { + delete Object.prototype.polluted; + } + }); + + it('utils.get must not traverse into the prototype chain via __proto__', function() { + // Without the guard this returns Object.prototype.toString (a function). + assert.strictEqual(apos.util.get({ a: 1 }, '__proto__.toString'), undefined); + assert.strictEqual(apos.util.get({ a: 1 }, '__proto__'), undefined); + }); + + it('implementPatchOperators must not let a $pullAll key pollute Object.prototype', function() { + // The reported attack vector: an authenticated editor PATCH body of + // { $pullAll: { '__proto__.publicApiProjection': [] } } reached + // apos.util.set with a fully attacker-controlled key. + const patch = { + $pullAll: { + '__proto__.publicApiProjection': [] + } + }; + try { + assert.throws(() => { + apos.schema.implementPatchOperators(patch, {}); + }, { name: 'invalid' }); + assert.strictEqual({}.publicApiProjection, undefined); + } finally { + delete Object.prototype.publicApiProjection; + } + }); + + it('implementPatchOperators must not let a direct dot-notation key pollute Object.prototype', function() { + // The second documented entry point: a top-level dotted key whose value + // is fully attacker-controlled. + const patch = { + '__proto__.publicApiProjection': { title: 1 } + }; + try { + assert.throws(() => { + apos.schema.implementPatchOperators(patch, {}); + }, { name: 'invalid' }); + assert.strictEqual({}.publicApiProjection, undefined); + } finally { + delete Object.prototype.publicApiProjection; + } + }); + it('should slugify', function () { // Basic assert.equal( From 6d48f1f2cdc17ee9082c5e5a29b995b004ad77b3 Mon Sep 17 00:00:00 2001 From: Tom Boutell <tom@apostrophecms.com> Date: Wed, 10 Jun 2026 11:46:07 -0400 Subject: [PATCH 29/39] Merge commit from fork --- .changeset/seo-analytics-xss.md | 5 + packages/seo/lib/nodes.js | 32 ++++-- packages/seo/test/unit-tests.js | 176 ++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 .changeset/seo-analytics-xss.md diff --git a/.changeset/seo-analytics-xss.md b/.changeset/seo-analytics-xss.md new file mode 100644 index 0000000000..e9b1f65d3b --- /dev/null +++ b/.changeset/seo-analytics-xss.md @@ -0,0 +1,5 @@ +--- +"@apostrophecms/seo": patch +--- + +Security: the Google Analytics tracking ID (`seoGoogleTrackingId`) and Google Tag Manager ID (`seoGoogleTagManager`) global SEO fields were interpolated directly into the bodies of inline `<script>` tags without escaping. Any user permitted to edit the global document, including editors and contributors (if their submission were approved), could set these fields to a value that broke out of the surrounding script and executed arbitrary JavaScript for every visitor on every page (stored XSS). These values are now emitted as escaped `json` nodes, matching the JSON-LD handling, so they can no longer terminate the `<script>` element or escape the string literal they sit in. All projects using `@apostrophecms/seo` with untrusted editors should upgrade promptly to close this vulnerability. Thanks to [H3xV0rT3x](https://github.com/H3xV0rT3x) and [hibrian827](https://github.com/hibrian827) for reporting the issue. diff --git a/packages/seo/lib/nodes.js b/packages/seo/lib/nodes.js index 01731cfcae..b83ba5e373 100644 --- a/packages/seo/lib/nodes.js +++ b/packages/seo/lib/nodes.js @@ -214,14 +214,21 @@ function getMetaHead(data, options) { }); nodes.push({ name: 'script', - body: [ { - raw: ` + // seoGoogleTrackingId is editor-controlled, so it is emitted as a `json` + // node (escaped by renderNodes via safeJsonForScript) rather than + // interpolated raw into the script body, which would be stored XSS. + // See the JSON-LD note below. + body: [ + { + raw: ` window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); - gtag('config', '${global.seoGoogleTrackingId}'); -` - } ] + gtag('config', ` + }, + { json: global.seoGoogleTrackingId }, + { raw: ');\n' } + ] }); } @@ -294,13 +301,20 @@ function getTagManagerHead(data) { nodes.push({ name: 'script', - body: [ { - raw: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': + // seoGoogleTagManager is editor-controlled, so it is emitted as a `json` + // node (escaped by renderNodes via safeJsonForScript) rather than + // interpolated raw into the script body, which would be stored XSS. + body: [ + { + raw: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); - })(window,document,'script','dataLayer','${global.seoGoogleTagManager}');` - } ] + })(window,document,'script','dataLayer',` + }, + { json: global.seoGoogleTagManager }, + { raw: ');' } + ] }); nodes.push({ diff --git a/packages/seo/test/unit-tests.js b/packages/seo/test/unit-tests.js index 095d82077f..e581c69a46 100644 --- a/packages/seo/test/unit-tests.js +++ b/packages/seo/test/unit-tests.js @@ -1716,6 +1716,182 @@ describe('@apostrophecms/seo', function () { }); }); + describe('XSS prevention in analytics scripts', function () { + const safeJsonForScript = require('apostrophe/lib/safe-json-script'); + + // Breaks out of the single-quoted JS string literal AND closes the + // surrounding <script> element under the old raw interpolation. + const payload = 'G-FAKE\'); alert(document.cookie); </script><script>alert(1)//'; + + it('should emit seoGoogleTrackingId as a json node, not raw interpolation', function () { + const { getMetaHead } = require('../lib/nodes'); + + const data = { + page: { _url: 'https://example.com/' }, + global: { seoGoogleTrackingId: payload }, + req: {} + }; + + const nodes = getMetaHead(data, {}); + + // The inline gtag() config script (a body array, not the async src + // loader, and not the application/ld+json block). + const inlineScript = nodes.find(n => + n.name === 'script' && + Array.isArray(n.body) && + n.attrs?.type !== 'application/ld+json' + ); + assert(inlineScript, 'inline gtag config script node should exist'); + + // The tracking id must be a json node so renderNodes escapes it, not a + // raw string with the id interpolated in. + const jsonSegment = inlineScript.body.find(seg => seg.json != null); + assert(jsonSegment, 'tracking id must be emitted as a json node'); + assert.strictEqual(jsonSegment.json, payload); + + const rawWithPayload = inlineScript.body.find( + seg => seg.raw != null && seg.raw.includes(payload) + ); + assert(!rawWithPayload, 'tracking id must not be interpolated into a raw segment'); + + // Once rendered, the payload cannot terminate the <script> element. + const rendered = safeJsonForScript(jsonSegment.json); + assert( + !/<\/script/i.test(rendered), + 'rendered tracking id must not contain an unescaped </script> sequence' + ); + }); + + it('should emit seoGoogleTagManager as a json node, not raw interpolation', function () { + const { getTagManagerHead } = require('../lib/nodes'); + + const data = { + global: { seoGoogleTagManager: payload }, + req: {} + }; + + const nodes = getTagManagerHead(data); + + const script = nodes.find(n => + n.name === 'script' && Array.isArray(n.body) + ); + assert(script, 'GTM loader script node should exist'); + + const jsonSegment = script.body.find(seg => seg.json != null); + assert(jsonSegment, 'GTM id must be emitted as a json node'); + assert.strictEqual(jsonSegment.json, payload); + + const rawWithPayload = script.body.find( + seg => seg.raw != null && seg.raw.includes(payload) + ); + assert(!rawWithPayload, 'GTM id must not be interpolated into a raw segment'); + + const rendered = safeJsonForScript(jsonSegment.json); + assert( + !/<\/script/i.test(rendered), + 'rendered GTM id must not contain an unescaped </script> sequence' + ); + }); + }); + + describe('analytics scripts with valid IDs', function () { + const safeJsonForScript = require('apostrophe/lib/safe-json-script'); + + // Render a script body the same way apos.template.renderNodes does: raw + // segments pass through verbatim, json segments go through the safe + // encoder. This lets us assert on the real rendered output (and prove the + // escaping fix did not break the normal, working snippet) without booting + // a full Apostrophe instance. + function renderBody(body) { + return body + .map(seg => { + if (seg.raw != null) { + return seg.raw; + } + if (seg.json != null) { + return safeJsonForScript(seg.json); + } + return ''; + }) + .join(''); + } + + it('should emit a working gtag config call when seoGoogleTrackingId is a normal id', function () { + const { getMetaHead } = require('../lib/nodes'); + + const trackingId = 'G-ABC1234567'; + + const data = { + page: { _url: 'https://example.com/' }, + global: { seoGoogleTrackingId: trackingId }, + req: {} + }; + + const nodes = getMetaHead(data, {}); + + // The async gtag.js loader carries the id in its src. + const loader = nodes.find(n => + n.name === 'script' && + n.attrs?.src?.startsWith('https://www.googletagmanager.com/gtag/js') + ); + assert(loader, 'gtag.js loader script should exist'); + assert.strictEqual( + loader.attrs.src, + `https://www.googletagmanager.com/gtag/js?id=${trackingId}` + ); + + // The inline config script carries the id as a json node. + const inlineScript = nodes.find(n => + n.name === 'script' && + Array.isArray(n.body) && + n.attrs?.type !== 'application/ld+json' + ); + assert(inlineScript, 'inline gtag config script node should exist'); + + const jsonSegment = inlineScript.body.find(seg => seg.json != null); + assert(jsonSegment, 'tracking id should be emitted as a json node'); + assert.strictEqual(jsonSegment.json, trackingId); + + // Rendered, the snippet is valid, working JS that configures gtag with + // the exact id as a quoted string: gtag('config', "G-ABC1234567"); + const rendered = renderBody(inlineScript.body); + assert( + rendered.includes(`gtag('config', "${trackingId}");`), + 'rendered gtag config should call gtag with the exact id as a quoted string' + ); + }); + + it('should emit a working GTM loader call when seoGoogleTagManager is a normal id', function () { + const { getTagManagerHead } = require('../lib/nodes'); + + const containerId = 'GTM-ABCD123'; + + const data = { + global: { seoGoogleTagManager: containerId }, + req: {} + }; + + const nodes = getTagManagerHead(data); + + const script = nodes.find(n => + n.name === 'script' && Array.isArray(n.body) + ); + assert(script, 'GTM loader script node should exist'); + + const jsonSegment = script.body.find(seg => seg.json != null); + assert(jsonSegment, 'GTM id should be emitted as a json node'); + assert.strictEqual(jsonSegment.json, containerId); + + // Rendered, the IIFE receives the exact id as a quoted string: + // ...,'dataLayer',"GTM-ABCD123"); + const rendered = renderBody(script.body); + assert( + rendered.includes(`'dataLayer',"${containerId}");`), + 'rendered GTM snippet should pass the exact id as a quoted string' + ); + }); + }); + describe('Schema Validation', function () { it('should validate Article schema requirements', function () { From 20a29eab105360539258f17d868b33424cc37f69 Mon Sep 17 00:00:00 2001 From: Tom Boutell <tom@apostrophecms.com> Date: Wed, 10 Jun 2026 11:46:23 -0400 Subject: [PATCH 30/39] Merge commit from fork --- .../file-pretty-url-host-header-ssrf.md | 5 ++++ .../modules/@apostrophecms/file/index.js | 17 +++++------ packages/apostrophe/test/files.js | 28 +++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 .changeset/file-pretty-url-host-header-ssrf.md diff --git a/.changeset/file-pretty-url-host-header-ssrf.md b/.changeset/file-pretty-url-host-header-ssrf.md new file mode 100644 index 0000000000..e071a14d9e --- /dev/null +++ b/.changeset/file-pretty-url-host-header-ssrf.md @@ -0,0 +1,5 @@ +--- +"apostrophe": patch +--- + +Security: when `@apostrophecms/file` pretty URLs are enabled (`prettyUrls: true`), the upstream request used to serve the file is no longer built from the incoming `Host` header. The self-request is now resolved against the site's configured `baseUrl` (via `req.baseUrl`), falling back to the request host only when no `baseUrl` is configured. This closes a server-side request forgery (SSRF) vector in which the `Host` header could steer the proxied fetch at another host. The real-world risk was low: the path is constrained to an existing attachment's `/uploads/attachments/<cuid>-<slug>.<ext>`, and cuids are unique and immutable, so any reachable content was already public via the front door. Thanks to [EchoSkorJjj](https://github.com/EchoSkorJjj) for reporting the issue. diff --git a/packages/apostrophe/modules/@apostrophecms/file/index.js b/packages/apostrophe/modules/@apostrophecms/file/index.js index ec6866c3d9..c3f156b39b 100644 --- a/packages/apostrophe/modules/@apostrophecms/file/index.js +++ b/packages/apostrophe/modules/@apostrophecms/file/index.js @@ -248,14 +248,15 @@ module.exports = { const uglyUrl = self.apos.attachment.url(file.attachment, { prettyUrl: false }); - // For relative URLs (local uploadfs, not CDN), resolve - // against the current server's origin so the proxy can - // make the self-request. During static builds - // `attachment.url()` may return only a path. - const proxyUrl = uglyUrl.startsWith('/') - ? `${req.protocol}://${req.get('host')}${uglyUrl}` - : uglyUrl; - return await streamProxy(req, proxyUrl, { error: self.apos.util.error }); + // `uglyUrl` may be relative (local uploadfs) or absolute + // (S3/CDN). For the relative case `streamProxy` resolves it + // against `req.baseUrl`, which reflects the configured + // `baseUrl` (or locale hostname) and only falls back to the + // request host when the site has none. We deliberately do + // not build the upstream URL from the raw `Host` header + // here, as that is attacker-controlled and would allow the + // self-request to be redirected to an arbitrary host (SSRF). + return await streamProxy(req, uglyUrl, { error: self.apos.util.error }); } catch (e) { self.apos.util.error('Error in pretty URL route:', e); return res.status(500).send('error'); diff --git a/packages/apostrophe/test/files.js b/packages/apostrophe/test/files.js index f2b2e48959..a35d5adebf 100644 --- a/packages/apostrophe/test/files.js +++ b/packages/apostrophe/test/files.js @@ -132,6 +132,34 @@ describe('Files', function() { } }); + it('should ignore a spoofed Host header when proxying the pretty URL (SSRF regression)', async function() { + const req = apos.task.getAnonReq(); + try { + apos.file.options.prettyUrls = true; + const files = await apos.file.find(req).toArray(); + assert.strictEqual(files.length, 1); + const file = files[0]; + const attachment = apos.attachment.first(file); + const url = apos.attachment.url(attachment); + assert(url); + // Send an attacker-controlled Host header (e.g. the cloud metadata + // address from the advisory). The upstream fetch must be resolved + // against the server-trusted baseUrl, not this header, so the + // legitimate content is still served and the request is never + // steered at the spoofed host. + const response = await apos.http.get(url, { + headers: { + Host: '169.254.169.254' + }, + fullResponse: true + }); + assert.strictEqual(response.status, 200); + assert.strictEqual(response.body, attachment.data); + } finally { + apos.file.options.prettyUrls = false; + } + }); + }); describe('Files with i18n locale prefixes', function() { From 736d6c48a79ac274b43a74962563415f3116dd30 Mon Sep 17 00:00:00 2001 From: Tom Boutell <tom@apostrophecms.com> Date: Wed, 10 Jun 2026 11:49:59 -0400 Subject: [PATCH 31/39] Merge commit from fork * Security: added a number of new attributes to be protected against unsafe URLs, e.g. javascript: and similar. None of these are used in the default configuration of sanitize-html or apostrophe or likely to be used there, and some attributes, like an action for a form, are inherently unsafe to allow if XSS protection is your goal. Nevertheless it makes sense to block certain URL types where they are not appropriate. Thanks to [crattack](https://github.com/crattack) for reporting the vulnerability. * changeset * removed duplicate changeset * patch the right module --- .changeset/boozy-manual-severaltoms.md | 5 ++ packages/sanitize-html/README.md | 9 ++- packages/sanitize-html/index.js | 13 +++- packages/sanitize-html/test/test.js | 92 ++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 .changeset/boozy-manual-severaltoms.md diff --git a/.changeset/boozy-manual-severaltoms.md b/.changeset/boozy-manual-severaltoms.md new file mode 100644 index 0000000000..7b2e41169b --- /dev/null +++ b/.changeset/boozy-manual-severaltoms.md @@ -0,0 +1,5 @@ +--- +"sanitize-html": patch +--- + +Security: added a number of new attributes to be protected against unsafe URLs, e.g. `javascript:` and similar. None of these are used in the default configuration of `sanitize-html` or `apostrophe` or likely to be used there, and some attributes, like an `action` for a `form`, are inherently unsafe to allow if XSS protection is your goal. Nevertheless it makes sense to block certain URL types where they are not appropriate. Some attributes are not supported at all by modern browsers but are included for completeness. Thanks to [crattack](https://github.com/crattack) for reporting the vulnerability. diff --git a/packages/sanitize-html/README.md b/packages/sanitize-html/README.md index fca839f93d..2dfefb4504 100644 --- a/packages/sanitize-html/README.md +++ b/packages/sanitize-html/README.md @@ -182,7 +182,14 @@ selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', ' // URL schemes we permit allowedSchemes: [ 'http', 'https', 'ftp', 'mailto', 'tel' ], allowedSchemesByTag: {}, -allowedSchemesAppliedToAttributes: [ 'href', 'src', 'cite' ], +allowedSchemesAppliedToAttributes: [ + 'href', 'src', 'cite', + 'action', 'formaction', 'data', 'xlink:href', + 'poster', 'background', 'ping', + 'longdesc', 'usemap', 'codebase', 'classid', 'archive', + 'profile', 'manifest', 'itemid', + 'dynsrc', 'lowsrc' +], allowProtocolRelative: true, enforceHtmlBoundary: false, parseStyleAttributes: true diff --git a/packages/sanitize-html/index.js b/packages/sanitize-html/index.js index b5da859f7e..7a616cca60 100644 --- a/packages/sanitize-html/index.js +++ b/packages/sanitize-html/index.js @@ -440,11 +440,11 @@ function sanitizeHtml(html, options, _recursing) { return; } } - if (a === 'srcset') { + if (a === 'srcset' || a === 'imagesrcset') { try { let parsed = parseSrcset(value); parsed.forEach(function(value) { - if (naughtyHref('srcset', value.url)) { + if (naughtyHref(a, value.url)) { value.evil = true; } }); @@ -957,7 +957,14 @@ sanitizeHtml.defaults = { // URL schemes we permit allowedSchemes: [ 'http', 'https', 'ftp', 'mailto', 'tel' ], allowedSchemesByTag: {}, - allowedSchemesAppliedToAttributes: [ 'href', 'src', 'cite' ], + allowedSchemesAppliedToAttributes: [ + 'href', 'src', 'cite', + 'action', 'formaction', 'data', 'xlink:href', + 'poster', 'background', 'ping', + 'longdesc', 'usemap', 'codebase', 'classid', 'archive', + 'profile', 'manifest', 'itemid', + 'dynsrc', 'lowsrc' + ], allowProtocolRelative: true, enforceHtmlBoundary: false, parseStyleAttributes: true, diff --git a/packages/sanitize-html/test/test.js b/packages/sanitize-html/test/test.js index f757d7f286..db3d877f5e 100644 --- a/packages/sanitize-html/test/test.js +++ b/packages/sanitize-html/test/test.js @@ -969,6 +969,98 @@ describe('sanitizeHtml', function() { '<img src="fallback.jpg" srcset="/upload/f_auto,q_auto:eco,c_fit,w_1460,h_2191/abc.jpg 1460w, /upload/f_auto,q_auto:eco,c_fit,w_1360,h_2041/abc.jpg" />' ); }); + it('should drop javascript: in form action', function() { + assert.equal( + sanitizeHtml('<form action="javascript:alert(1)"><button>x</button></form>', { + allowedTags: [ 'form', 'button' ], + allowedAttributes: { form: [ 'action' ] } + }), + '<form><button>x</button></form>' + ); + }); + it('should allow http(s) in form action', function() { + assert.equal( + sanitizeHtml('<form action="https://example.com/submit"><button>x</button></form>', { + allowedTags: [ 'form', 'button' ], + allowedAttributes: { form: [ 'action' ] } + }), + '<form action="https://example.com/submit"><button>x</button></form>' + ); + }); + it('should drop javascript: in button formaction', function() { + assert.equal( + sanitizeHtml('<button formaction="javascript:alert(1)">x</button>', { + allowedTags: [ 'button' ], + allowedAttributes: { button: [ 'formaction' ] } + }), + '<button>x</button>' + ); + }); + it('should allow http(s) in button formaction', function() { + assert.equal( + sanitizeHtml('<button formaction="https://example.com/submit">x</button>', { + allowedTags: [ 'button' ], + allowedAttributes: { button: [ 'formaction' ] } + }), + '<button formaction="https://example.com/submit">x</button>' + ); + }); + it('should drop javascript: in object data', function() { + assert.equal( + sanitizeHtml('<object data="javascript:alert(1)"></object>', { + allowedTags: [ 'object' ], + allowedAttributes: { object: [ 'data' ] } + }), + '<object></object>' + ); + }); + it('should allow http(s) in object data', function() { + assert.equal( + sanitizeHtml('<object data="https://example.com/file.pdf"></object>', { + allowedTags: [ 'object' ], + allowedAttributes: { object: [ 'data' ] } + }), + '<object data="https://example.com/file.pdf"></object>' + ); + }); + it('should drop javascript: in SVG xlink:href', function() { + assert.equal( + sanitizeHtml('<svg><a xlink:href="javascript:alert(1)"><text>x</text></a></svg>', { + allowedTags: [ 'svg', 'a', 'text' ], + allowedAttributes: { a: [ 'xlink:href' ] } + }), + '<svg><a><text>x</text></a></svg>' + ); + }); + it('should allow http(s) in SVG xlink:href', function() { + assert.equal( + sanitizeHtml('<svg><a xlink:href="https://example.com/"><text>x</text></a></svg>', { + allowedTags: [ 'svg', 'a', 'text' ], + allowedAttributes: { a: [ 'xlink:href' ] } + }), + '<svg><a xlink:href="https://example.com/"><text>x</text></a></svg>' + ); + }); + it('should drop bogus imagesrcset', function() { + assert.equal( + sanitizeHtml('<link rel="preload" as="image" imagesrcset="foo.jpg 1x, javascript:alert(1) 2x" />', { + allowedTags: [ 'link' ], + allowedAttributes: { link: [ 'rel', 'as', 'imagesrcset' ] }, + selfClosing: [ 'link' ] + }), + '<link rel="preload" as="image" imagesrcset="foo.jpg 1x" />' + ); + }); + it('should accept valid imagesrcset', function() { + assert.equal( + sanitizeHtml('<link rel="preload" as="image" imagesrcset="foo.jpg 1x, bar.jpg 2x" />', { + allowedTags: [ 'link' ], + allowedAttributes: { link: [ 'rel', 'as', 'imagesrcset' ] }, + selfClosing: [ 'link' ] + }), + '<link rel="preload" as="image" imagesrcset="foo.jpg 1x, bar.jpg 2x" />' + ); + }); it('text from transformTags should not specify tags', function() { const input = '<input value="&lt;script&gt;alert(1)&lt;/script&gt;">'; From 5fda79eeeda3b13b0e85cc9fc2aacdb361621baf Mon Sep 17 00:00:00 2001 From: Tom Boutell <tom@apostrophecms.com> Date: Wed, 10 Jun 2026 14:51:46 -0400 Subject: [PATCH 32/39] Mergeback latest (#5468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Latest merge prerelease 2026 05 (#5404) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> (cherry picked from commit 9f458b5d9544d33239a0971b0050fff8bffe028a) * Bump CLI dependencies (#5383) (cherry picked from commit a5e1a4a35d01e09cbfd2c503370c95a8c2490867) * Native browser shortcuts work again (#5384) (cherry picked from commit b9b32bdd446add927c75d85e44d0a26814ba62b0) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments (cherry picked from commit 08845c5f23d95ead552ab9743a878daf997793a1) * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> (cherry picked from commit d45e27f6d66cd25fde0cf9c7199ee9d014fa1518) * merge back the thanks (#5388) (cherry picked from commit f3501f4de0448d4d0f8a01a1db4dc2fcf52c6218) * ignore inline table array as draggable ui for windows (#5392) (cherry picked from commit b360b05e8d4e479a36fcae45a5a6dbf820b8fba3) * Layout focus orchestration (#5393) (cherry picked from commit 77a2968206b0208883ff45de37feb43ba0b5d5b1) * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures (cherry picked from commit 008417fa9814dba4899b53b9394778dbd46ccf3c) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed (cherry picked from commit e9b3bac692315414da47fe05202e8854dd31d7ee) * Layout editable gap (#5397) (cherry picked from commit bc8f7bed381d4462a082976dd92b8d7f28180a95) * a11y fixes (#5401) (cherry picked from commit 2e2f3b4b46301fc81c46200f837e794b247a9aef) * clarifications (#5403) (cherry picked from commit 13f2c69f219a47f82972d8fa9036deddd84223dc) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Latest security merge (#5407) * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * Merge commit from fork * Merge commit from fork * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * release only (changelogs formatted) (#5408) * allow oembetter to be released (#5412) * release oembetter 1.2.0 (#5413) * release oembetter 1.2.0 * left commit * [latest[ PRO-9441: modal focus trap * [latest] Fix import-export noise * [latest] A11y fixes part 3 * Fix initial focus trap issue, introduced with recent changes (#5427) * merge main to latest (#5460) * Latest security q2 (#5463) * Bump CLI dependencies (#5383) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> * Native browser shortcuts work again (#5384) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> * merge back the thanks (#5388) * Postgres (#5365) * postgres experimental WIP * astonishingly, all mocha tests of apostrophe pass with this * mocha tests pass, actual sites work * lint clean * listDatabases support, but changes are coming * wip * dump and restore updates * backpressure, adequate handling of ObjectId for our needs (becomes its hex representation) * mild performance optimization * profiling * testing issue resolved * refactored to db-connect module, introduced sqlite adapter * sqlite WIP * debugging * programmatic API for dump/restore/copy dbs * linting, documentation * MIT license * text ranking is more accurate, documentation is more complete * good full text search for sqlite * updates for compatibility with the rest of the public and private modules, plus a few fixes to genuinely ambiguous tests * requirements found by testing private modules * fixes from full cypress run * eslint passing * restore permissions * maximize atomicity * bug fixes * * exit properly when asset tests fail * "npm test" tests all three adapters * ignore claude-tools in eslint * postgres and sqlite-inclusive ci matrix attempt * clean up logs * We hit github's limit on total configurations because every package gets its own matrix. Solve that with grouping: * apostrophe core * All regular ecosystem packages other than core * non-database-requiring packges * mongodb-specific packages This will probably speed it up too because it won't have to spin up a container a bazillion times. * hardened the asset tests, made them less timing sensitive, fixed a bad commit resulting from the way they dodgily patch themselves without a robust cleanup mechanism * fix a root cause of asset test instability * log mess * implemented missing $size operator * test compatibility * advanced permission uses regex in $in * regex in $in * .db() should not make false promises in plain postgres mode, it should fail * ability to specify a default adapter * obsolete file * put escapeHost back where it belongs * dead code removal, test cleanup * emulate-mongo-3-driver only needed in db-connect * no claude logs in repo (tools are welcome) * * shared aggregation implementation, other shared things * optimize $match when it is the first step in aggregation, don't fetch the whole collection 😜 * multipostgres listDatabases() and .db() should return and expect "fully qualified virtual database names," e.g. physical_db_name-schemaname * vanilla postgres should not attempt to use .db() with alternate names in tests * documentation corrections * documentation errors * listDatabases and documentation corrections * more edge cases revealed by latest work from Miro * anchored prefix regexps are optimized documentation improvements * * matchesQuery in the aggregation cursor implementation doesn't throw on unrecognized operators. It should, and it should support the same mongodb operators that the regular find() path does in postgres/sqlite (our official subset), unless there is an extraordinary reason not to. * Similarly, the main query implementation for normal queries should throw on unrecognized operators if it doesn't already. * The dump/restore programmatic APIs in db-connect concern me. These involve returning the entire database as a string, which could exhaust memory. This impacts both utilities and also copyDatabase(). Could these APIs return and expect async iterators instead of strings? * The test "anchored regex on an indexed field uses a btree index search" runs explain on a query that's hardcoded in the test. Instead these SQL based adapters should expose a means to get the SQL for a query, so it can be directly tested. Otherwise this test proves nothing as changes to the adapter accumulate in future. * Why is this test searching for "at least 1" and not exactly 1? it('should find documents with null value', async function() { const docs = await db.collection('test').find({ value: null }).toArray(); // MongoDB matches both null and missing fields with { value: null } expect(docs.length).to.be.at.least(1); }); * What is the maximum size of a db-connect document in the postgres and sqlite adapters? * Update the copyright year in db-connect/LICENSE.md to 2025. * The db-connect README mentions: sqlite://:memory: What happens if you try to use .db('some-name') with that? I think it would be best to just not support throwaway in-memory sqlite databases because I doubt anyone would intentionally store a website in one. * do not swallow dump/restore errors on indexes * cover how to run the utilities * fix detection of source * separate sanitization for index names * regex prefix safety * pnpm --------- Co-authored-by: Thomas Boutell <boutell@vcs.trox.local> * forgot to include a changeset (#5390) * ignore inline table array as draggable ui for windows (#5392) * Layout focus orchestration (#5393) * Pro 9405 remove hreflang (#5395) * Remove hreflang generation and update README * Add changeset * Changeset update * Pro 9406 base url (#5396) * Removes `seoSiteCanonicalUrl` * Update tests and remove missed log * Change semver level * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures * Pro 9405 remove hreflang (#5395) * Remove hreflang generation and update README * Add changeset * Changeset update * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed * Layout editable gap (#5397) * a11y fixes (#5401) * clarifications (#5403) * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * Merge commit from fork * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * mergeback (#5409) * Latest merge prerelease 2026 05 (#5404) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> (cherry picked from commit 9f458b5d9544d33239a0971b0050fff8bffe028a) * Bump CLI dependencies (#5383) (cherry picked from commit a5e1a4a35d01e09cbfd2c503370c95a8c2490867) * Native browser shortcuts work again (#5384) (cherry picked from commit b9b32bdd446add927c75d85e44d0a26814ba62b0) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments (cherry picked from commit 08845c5f23d95ead552ab9743a878daf997793a1) * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> (cherry picked from commit d45e27f6d66cd25fde0cf9c7199ee9d014fa1518) * merge back the thanks (#5388) (cherry picked from commit f3501f4de0448d4d0f8a01a1db4dc2fcf52c6218) * ignore inline table array as draggable ui for windows (#5392) (cherry picked from commit b360b05e8d4e479a36fcae45a5a6dbf820b8fba3) * Layout focus orchestration (#5393) (cherry picked from commit 77a2968206b0208883ff45de37feb43ba0b5d5b1) * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures (cherry picked from commit 008417fa9814dba4899b53b9394778dbd46ccf3c) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed (cherry picked from commit e9b3bac692315414da47fe05202e8854dd31d7ee) * Layout editable gap (#5397) (cherry picked from commit bc8f7bed381d4462a082976dd92b8d7f28180a95) * a11y fixes (#5401) (cherry picked from commit 2e2f3b4b46301fc81c46200f837e794b247a9aef) * clarifications (#5403) (cherry picked from commit 13f2c69f219a47f82972d8fa9036deddd84223dc) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Latest security merge (#5407) * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * Merge commit from fork * Merge commit from fork * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * release only (changelogs formatted) (#5408) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * mergeback (#5414) * Latest merge prerelease 2026 05 (#5404) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> (cherry picked from commit 9f458b5d9544d33239a0971b0050fff8bffe028a) * Bump CLI dependencies (#5383) (cherry picked from commit a5e1a4a35d01e09cbfd2c503370c95a8c2490867) * Native browser shortcuts work again (#5384) (cherry picked from commit b9b32bdd446add927c75d85e44d0a26814ba62b0) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments (cherry picked from commit 08845c5f23d95ead552ab9743a878daf997793a1) * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> (cherry picked from commit d45e27f6d66cd25fde0cf9c7199ee9d014fa1518) * merge back the thanks (#5388) (cherry picked from commit f3501f4de0448d4d0f8a01a1db4dc2fcf52c6218) * ignore inline table array as draggable ui for windows (#5392) (cherry picked from commit b360b05e8d4e479a36fcae45a5a6dbf820b8fba3) * Layout focus orchestration (#5393) (cherry picked from commit 77a2968206b0208883ff45de37feb43ba0b5d5b1) * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures (cherry picked from commit 008417fa9814dba4899b53b9394778dbd46ccf3c) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed (cherry picked from commit e9b3bac692315414da47fe05202e8854dd31d7ee) * Layout editable gap (#5397) (cherry picked from commit bc8f7bed381d4462a082976dd92b8d7f28180a95) * a11y fixes (#5401) (cherry picked from commit 2e2f3b4b46301fc81c46200f837e794b247a9aef) * clarifications (#5403) (cherry picked from commit 13f2c69f219a47f82972d8fa9036deddd84223dc) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Latest security merge (#5407) * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * Merge commit from fork * Merge commit from fork * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * release only (changelogs formatted) (#5408) * allow oembetter to be released (#5412) * release oembetter 1.2.0 (#5413) * release oembetter 1.2.0 * left commit --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Fix focus trap on the last element in a modal (#5406) * Fix focus trap on the last element in a modal * Fix trap escaping edge cases * Fix import-export noise (#5399) * remove noise, switch to utils debug * Fix tests * Introduce debug option * changelog * Fix test sorting issue * A11y fixes part 3 (#5416) * Fix editor modal a11y issues * Fix manager a11y problems * Fix page manager a11y problems * fix media manager a11y issues * fix a11y issues in style editor and user settings * Fix login a11y issues * eliminate a modal issue * Remove bad aria in rich text * Fix wrong aria in layout * changelog * Fix totp a11y issues, doc context state safety * Fix uncaught error - popup blockers/tests * Fix initial focus trap issue, introduced with recent changes (#5426) * PRO-9542: fix the bug that breaks sitemaps for RA (#5433) * PRO-9542: fix the bug that breaks sitemaps for RA * see changeset * Feature/prevent infinite redirects (#5429) * log aposResponse errors * add changeset * prevent infinite redirects to external URLs --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> * add draggable: false support to non-inline array field (#5435) * add draggable: false support to non-inline array field * Add changeset * Make logged-in cookie name configurable via options (#5430) The logged-in cookie name was hardcoded as 'loggedIn' with TODO comments indicating it should be configurable. This is needed for deployments where multiple Apostrophe instances share a domain (e.g., staging and production on subpaths) and need distinct cookie names to avoid conflicts. Changes: - Added 'loggedInCookieName' option to the login module (defaults to 'loggedIn' for backward compatibility) - Replaced all hardcoded references with self.loggedInCookieName - Removed the TODO comments Usage: modules: { '@apostrophecms/login': { options: { loggedInCookieName: 'myAppLoggedIn' } } } Addresses the TODO comments: 'get cookie name from config' Co-authored-by: Vangalla, Rohith <rohith.vangalla@optum.com> * Revert "Make logged-in cookie name configurable via options (#5430)" (#5436) This reverts commit ddcdaa7ff6864cd55d6fef9dfdfca2eaf63a5969. * Feature create-apostrophe (#5425) * Fix new schema areas in existing documents (Astro) (#5434) * Fix orphan or new-in-the-schema areas in external front-ends * Save missing empty areas in the DB, refactor nunjucks path * PRO-6295: jsx as an optional alternative to nunjucks (#5391) * jsx as an optional alternative to nunjucks * eslint, all tests pass * log a useful stack trace on attachment errors! Holy shit! * fix lint * clarify behavior * more tests pass linter * This is just a unit test, but it can't hurt to be thorough & satisfy github-advanced-security Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * true access to the apos object in jsx, per the spec * more test coverage, no code changes * fix watchers --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * no watch in prod (#5439) * Fix new schema areas in existing documents (Astro) Part II (#5440) * Prevent data corruption when stubbing areas for Astro * Fix false positive orphan area warnings * Guard against corrupt area items * Fix raw-text sanitization bypass vulnerability and add regression tests (#5432) * changeset for singh contribution (#5442) * Fix relationship select scrolling issue (#5445) * Fix relationship select scrolling issue * Prevent same scrolling bugs to appear in media manager * jsx changeset (#5446) * Ensure install of the project root for astro projects (#5449) * test node 26 (#5450) * test node 26 * support node 26 by bumping the better-sqlite3 version * node 22 requirement * Add link for telemetry policy (#5455) * remove absent options (#5456) * Remove consumed 4.30.0 changesets from main (#5454) * cli links that are correct, or will be post publish (#5458) * release db connect to solve chicken and egg problem in cypress-tools (#5459) * Corrects documentation links (#5457) * Corrects documentation links * correct `guides` -> `guide` --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Thomas Boutell <boutell@vcs.trox.local> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> Co-authored-by: RohithVangalla1 <reachrohithv@gmail.com> Co-authored-by: Vangalla, Rohith <rohith.vangalla@optum.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Dipanshu singh <161134993+Dipanshusinghh@users.noreply.github.com> * Latest security q2 (#5464) * Bump CLI dependencies (#5383) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> * Native browser shortcuts work again (#5384) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> * merge back the thanks (#5388) * Postgres (#5365) * postgres experimental WIP * astonishingly, all mocha tests of apostrophe pass with this * mocha tests pass, actual sites work * lint clean * listDatabases support, but changes are coming * wip * dump and restore updates * backpressure, adequate handling of ObjectId for our needs (becomes its hex representation) * mild performance optimization * profiling * testing issue resolved * refactored to db-connect module, introduced sqlite adapter * sqlite WIP * debugging * programmatic API for dump/restore/copy dbs * linting, documentation * MIT license * text ranking is more accurate, documentation is more complete * good full text search for sqlite * updates for compatibility with the rest of the public and private modules, plus a few fixes to genuinely ambiguous tests * requirements found by testing private modules * fixes from full cypress run * eslint passing * restore permissions * maximize atomicity * bug fixes * * exit properly when asset tests fail * "npm test" tests all three adapters * ignore claude-tools in eslint * postgres and sqlite-inclusive ci matrix attempt * clean up logs * We hit github's limit on total configurations because every package gets its own matrix. Solve that with grouping: * apostrophe core * All regular ecosystem packages other than core * non-database-requiring packges * mongodb-specific packages This will probably speed it up too because it won't have to spin up a container a bazillion times. * hardened the asset tests, made them less timing sensitive, fixed a bad commit resulting from the way they dodgily patch themselves without a robust cleanup mechanism * fix a root cause of asset test instability * log mess * implemented missing $size operator * test compatibility * advanced permission uses regex in $in * regex in $in * .db() should not make false promises in plain postgres mode, it should fail * ability to specify a default adapter * obsolete file * put escapeHost back where it belongs * dead code removal, test cleanup * emulate-mongo-3-driver only needed in db-connect * no claude logs in repo (tools are welcome) * * shared aggregation implementation, other shared things * optimize $match when it is the first step in aggregation, don't fetch the whole collection 😜 * multipostgres listDatabases() and .db() should return and expect "fully qualified virtual database names," e.g. physical_db_name-schemaname * vanilla postgres should not attempt to use .db() with alternate names in tests * documentation corrections * documentation errors * listDatabases and documentation corrections * more edge cases revealed by latest work from Miro * anchored prefix regexps are optimized documentation improvements * * matchesQuery in the aggregation cursor implementation doesn't throw on unrecognized operators. It should, and it should support the same mongodb operators that the regular find() path does in postgres/sqlite (our official subset), unless there is an extraordinary reason not to. * Similarly, the main query implementation for normal queries should throw on unrecognized operators if it doesn't already. * The dump/restore programmatic APIs in db-connect concern me. These involve returning the entire database as a string, which could exhaust memory. This impacts both utilities and also copyDatabase(). Could these APIs return and expect async iterators instead of strings? * The test "anchored regex on an indexed field uses a btree index search" runs explain on a query that's hardcoded in the test. Instead these SQL based adapters should expose a means to get the SQL for a query, so it can be directly tested. Otherwise this test proves nothing as changes to the adapter accumulate in future. * Why is this test searching for "at least 1" and not exactly 1? it('should find documents with null value', async function() { const docs = await db.collection('test').find({ value: null }).toArray(); // MongoDB matches both null and missing fields with { value: null } expect(docs.length).to.be.at.least(1); }); * What is the maximum size of a db-connect document in the postgres and sqlite adapters? * Update the copyright year in db-connect/LICENSE.md to 2025. * The db-connect README mentions: sqlite://:memory: What happens if you try to use .db('some-name') with that? I think it would be best to just not support throwaway in-memory sqlite databases because I doubt anyone would intentionally store a website in one. * do not swallow dump/restore errors on indexes * cover how to run the utilities * fix detection of source * separate sanitization for index names * regex prefix safety * pnpm --------- Co-authored-by: Thomas Boutell <boutell@vcs.trox.local> * forgot to include a changeset (#5390) * ignore inline table array as draggable ui for windows (#5392) * Layout focus orchestration (#5393) * Pro 9405 remove hreflang (#5395) * Remove hreflang generation and update README * Add changeset * Changeset update * Pro 9406 base url (#5396) * Removes `seoSiteCanonicalUrl` * Update tests and remove missed log * Change semver level * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures * Pro 9405 remove hreflang (#5395) * Remove hreflang generation and update README * Add changeset * Changeset update * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed * Layout editable gap (#5397) * a11y fixes (#5401) * clarifications (#5403) * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * Merge commit from fork * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * mergeback (#5409) * Latest merge prerelease 2026 05 (#5404) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> (cherry picked from commit 9f458b5d9544d33239a0971b0050fff8bffe028a) * Bump CLI dependencies (#5383) (cherry picked from commit a5e1a4a35d01e09cbfd2c503370c95a8c2490867) * Native browser shortcuts work again (#5384) (cherry picked from commit b9b32bdd446add927c75d85e44d0a26814ba62b0) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments (cherry picked from commit 08845c5f23d95ead552ab9743a878daf997793a1) * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> (cherry picked from commit d45e27f6d66cd25fde0cf9c7199ee9d014fa1518) * merge back the thanks (#5388) (cherry picked from commit f3501f4de0448d4d0f8a01a1db4dc2fcf52c6218) * ignore inline table array as draggable ui for windows (#5392) (cherry picked from commit b360b05e8d4e479a36fcae45a5a6dbf820b8fba3) * Layout focus orchestration (#5393) (cherry picked from commit 77a2968206b0208883ff45de37feb43ba0b5d5b1) * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures (cherry picked from commit 008417fa9814dba4899b53b9394778dbd46ccf3c) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed (cherry picked from commit e9b3bac692315414da47fe05202e8854dd31d7ee) * Layout editable gap (#5397) (cherry picked from commit bc8f7bed381d4462a082976dd92b8d7f28180a95) * a11y fixes (#5401) (cherry picked from commit 2e2f3b4b46301fc81c46200f837e794b247a9aef) * clarifications (#5403) (cherry picked from commit 13f2c69f219a47f82972d8fa9036deddd84223dc) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Latest security merge (#5407) * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * Merge commit from fork * Merge commit from fork * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * release only (changelogs formatted) (#5408) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * mergeback (#5414) * Latest merge prerelease 2026 05 (#5404) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> (cherry picked from commit 9f458b5d9544d33239a0971b0050fff8bffe028a) * Bump CLI dependencies (#5383) (cherry picked from commit a5e1a4a35d01e09cbfd2c503370c95a8c2490867) * Native browser shortcuts work again (#5384) (cherry picked from commit b9b32bdd446add927c75d85e44d0a26814ba62b0) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments (cherry picked from commit 08845c5f23d95ead552ab9743a878daf997793a1) * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> (cherry picked from commit d45e27f6d66cd25fde0cf9c7199ee9d014fa1518) * merge back the thanks (#5388) (cherry picked from commit f3501f4de0448d4d0f8a01a1db4dc2fcf52c6218) * ignore inline table array as draggable ui for windows (#5392) (cherry picked from commit b360b05e8d4e479a36fcae45a5a6dbf820b8fba3) * Layout focus orchestration (#5393) (cherry picked from commit 77a2968206b0208883ff45de37feb43ba0b5d5b1) * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures (cherry picked from commit 008417fa9814dba4899b53b9394778dbd46ccf3c) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed (cherry picked from commit e9b3bac692315414da47fe05202e8854dd31d7ee) * Layout editable gap (#5397) (cherry picked from commit bc8f7bed381d4462a082976dd92b8d7f28180a95) * a11y fixes (#5401) (cherry picked from commit 2e2f3b4b46301fc81c46200f837e794b247a9aef) * clarifications (#5403) (cherry picked from commit 13f2c69f219a47f82972d8fa9036deddd84223dc) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Latest security merge (#5407) * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * Merge commit from fork * Merge commit from fork * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * release only (changelogs formatted) (#5408) * allow oembetter to be released (#5412) * release oembetter 1.2.0 (#5413) * release oembetter 1.2.0 * left commit --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Fix focus trap on the last element in a modal (#5406) * Fix focus trap on the last element in a modal * Fix trap escaping edge cases * Fix import-export noise (#5399) * remove noise, switch to utils debug * Fix tests * Introduce debug option * changelog * Fix test sorting issue * A11y fixes part 3 (#5416) * Fix editor modal a11y issues * Fix manager a11y problems * Fix page manager a11y problems * fix media manager a11y issues * fix a11y issues in style editor and user settings * Fix login a11y issues * eliminate a modal issue * Remove bad aria in rich text * Fix wrong aria in layout * changelog * Fix totp a11y issues, doc context state safety * Fix uncaught error - popup blockers/tests * Fix initial focus trap issue, introduced with recent changes (#5426) * PRO-9542: fix the bug that breaks sitemaps for RA (#5433) * PRO-9542: fix the bug that breaks sitemaps for RA * see changeset * Feature/prevent infinite redirects (#5429) * log aposResponse errors * add changeset * prevent infinite redirects to external URLs --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> * add draggable: false support to non-inline array field (#5435) * add draggable: false support to non-inline array field * Add changeset * Make logged-in cookie name configurable via options (#5430) The logged-in cookie name was hardcoded as 'loggedIn' with TODO comments indicating it should be configurable. This is needed for deployments where multiple Apostrophe instances share a domain (e.g., staging and production on subpaths) and need distinct cookie names to avoid conflicts. Changes: - Added 'loggedInCookieName' option to the login module (defaults to 'loggedIn' for backward compatibility) - Replaced all hardcoded references with self.loggedInCookieName - Removed the TODO comments Usage: modules: { '@apostrophecms/login': { options: { loggedInCookieName: 'myAppLoggedIn' } } } Addresses the TODO comments: 'get cookie name from config' Co-authored-by: Vangalla, Rohith <rohith.vangalla@optum.com> * Revert "Make logged-in cookie name configurable via options (#5430)" (#5436) This reverts commit ddcdaa7ff6864cd55d6fef9dfdfca2eaf63a5969. * Feature create-apostrophe (#5425) * Fix new schema areas in existing documents (Astro) (#5434) * Fix orphan or new-in-the-schema areas in external front-ends * Save missing empty areas in the DB, refactor nunjucks path * PRO-6295: jsx as an optional alternative to nunjucks (#5391) * jsx as an optional alternative to nunjucks * eslint, all tests pass * log a useful stack trace on attachment errors! Holy shit! * fix lint * clarify behavior * more tests pass linter * This is just a unit test, but it can't hurt to be thorough & satisfy github-advanced-security Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * true access to the apos object in jsx, per the spec * more test coverage, no code changes * fix watchers --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * no watch in prod (#5439) * Fix new schema areas in existing documents (Astro) Part II (#5440) * Prevent data corruption when stubbing areas for Astro * Fix false positive orphan area warnings * Guard against corrupt area items * Fix raw-text sanitization bypass vulnerability and add regression tests (#5432) * changeset for singh contribution (#5442) * Fix relationship select scrolling issue (#5445) * Fix relationship select scrolling issue * Prevent same scrolling bugs to appear in media manager * jsx changeset (#5446) * Ensure install of the project root for astro projects (#5449) * test node 26 (#5450) * test node 26 * support node 26 by bumping the better-sqlite3 version * node 22 requirement * Add link for telemetry policy (#5455) * remove absent options (#5456) * Remove consumed 4.30.0 changesets from main (#5454) * cli links that are correct, or will be post publish (#5458) * release db connect to solve chicken and egg problem in cypress-tools (#5459) * Corrects documentation links (#5457) * Corrects documentation links * correct `guides` -> `guide` * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Security: added a number of new attributes to be protected against unsafe URLs, e.g. javascript: and similar. None of these are used in the default configuration of sanitize-html or apostrophe or likely to be used there, and some attributes, like an action for a form, are inherently unsafe to allow if XSS protection is your goal. Nevertheless it makes sense to block certain URL types where they are not appropriate. Thanks to [crattack](https://github.com/crattack) for reporting the vulnerability. * changeset * removed duplicate changeset * patch the right module --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Thomas Boutell <boutell@vcs.trox.local> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> Co-authored-by: RohithVangalla1 <reachrohithv@gmail.com> Co-authored-by: Vangalla, Rohith <rohith.vangalla@optum.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Dipanshu singh <161134993+Dipanshusinghh@users.noreply.github.com> * release and changelog edits (#5465) * changelogs * formatting --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> Co-authored-by: Thomas Boutell <boutell@vcs.trox.local> Co-authored-by: RohithVangalla1 <reachrohithv@gmail.com> Co-authored-by: Vangalla, Rohith <rohith.vangalla@optum.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Dipanshu singh <161134993+Dipanshusinghh@users.noreply.github.com> --- packages/apostrophe-astro/CHANGELOG.md | 9 +++++++ packages/apostrophe-astro/package.json | 2 +- packages/apostrophe/CHANGELOG.md | 29 +++++++++++++++++++-- packages/apostrophe/package.json | 2 +- packages/cli/CHANGELOG.md | 6 +++++ packages/cli/package.json | 2 +- packages/import-export/CHANGELOG.md | 6 +++++ packages/import-export/package.json | 2 +- packages/redirect/CHANGELOG.md | 36 +++++++++++++++----------- packages/redirect/package.json | 2 +- packages/sanitize-html/CHANGELOG.md | 7 +++++ packages/sanitize-html/package.json | 2 +- packages/seo/CHANGELOG.md | 14 ++++++++++ packages/seo/package.json | 2 +- 14 files changed, 97 insertions(+), 24 deletions(-) diff --git a/packages/apostrophe-astro/CHANGELOG.md b/packages/apostrophe-astro/CHANGELOG.md index 95757b3190..48a13812ae 100644 --- a/packages/apostrophe-astro/CHANGELOG.md +++ b/packages/apostrophe-astro/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.13.0 (2026-06-10) + +### Fixes + +- Adding or removing an area field from a schema no longer breaks documents on an external front such as Astro. +- `AposArea` now renders only schema-backed areas. A missing area no longer throws, and an area orphaned by removing its field from the schema (while its content remains in the document) renders nothing instead of breaking sibling areas in edit mode. Logged-in editors get a diagnostic message in place of an orphaned area; anonymous visitors see nothing. +- Editable documents sent to an external front now materialize empty area objects for schema area fields added after the document was created, so they can be edited in context. +- `apos.util.getManagerOf` accepts a `{ log }` option to suppress its error log when probing objects that may not have a manager. + ## 1.12.0 ### Adds diff --git a/packages/apostrophe-astro/package.json b/packages/apostrophe-astro/package.json index 450d1d0d2c..f0b5def0ef 100644 --- a/packages/apostrophe-astro/package.json +++ b/packages/apostrophe-astro/package.json @@ -1,6 +1,6 @@ { "name": "@apostrophecms/apostrophe-astro", - "version": "1.12.0", + "version": "1.13.0", "type": "module", "description": "Apostrophe integration for Astro", "repository": { diff --git a/packages/apostrophe/CHANGELOG.md b/packages/apostrophe/CHANGELOG.md index 982e6952c0..8f9640d96f 100644 --- a/packages/apostrophe/CHANGELOG.md +++ b/packages/apostrophe/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 4.31.0 (2026-06-10) + +### Adds + +- Added support for `draggable: false` on non-inline `array` schema fields. Previously this option was only respected when `inline: true`. When set on a standard (modal-based) array field, drag-and-drop reordering and keyboard reordering are now disabled in the array editor's slat list. +- Introduced support for postgres://, sqlite://, and multipostgres:// database URIs in addition to mongodb://. The new db-connect API supports all of the database operations currently used in our own core, pro and multisite modules. For more information see the documentation. +- JSX support for templates within ApostropheCMS. JSX is now co-equal with Nunjucks, with a gradual migration strategy. Anyone who is familiar with React will be very comfortable writing JSX templates, which also offer a superior debugging experience, and templates can be migrated gradually. JSX is a great option for those who don't wish to create parallel Astro and ApostropheCMS projects, but still prefer a modern syntax. For more information, see the new [JSX templates guide](https://apostrophecms.com/docs/guide/jsx-templates.html). +- The session secret and the uploadfs `disabledFileKey` can now be supplied via the `APOS_SESSION_SECRET` and `APOS_UPLOADFS_DISABLED_FILE_KEY` environment variables. As with other Apostrophe environment variables, these take precedence over the corresponding `app.js` configuration. + +### Fixes + +- Fixed an issue where using the Tab key to navigate within modals could incorrectly jump focus to a wrong element instead of the next input field. +Fixed Tab navigation escaping out of modals when the form contained hidden sections or elements that became disabled after editing. +- Fixed adding or removing an area field from a schema breaking existing documents on an external front such as Astro. +- For Astro: `AposArea` now renders only schema-backed areas. A missing area no longer throws, and an area orphaned by removing its field from the schema (while its content remains in the document) renders nothing instead of breaking sibling areas in edit mode. Logged-in editors get a diagnostic message in place of an orphaned area; anonymous visitors see nothing. +- Editable documents sent to an external front (Asgtro) now materialize empty area objects for schema area fields added after the document was created, so they can be edited in context. +- `apos.util.getManagerOf` accepts a `{ log }` option to suppress its error log when probing objects that may not have a manager. +- Fix more admin UI a11y issues. +- Selecting an item in a relationship "browse" dialog no longer scrolls the title and Cancel/Select buttons out of view when the item is far down the list. +- Sites with a custom filterByIndexPage method no longer experience failures in the sitemap module and potential creeping CPU performance penalties. A regression introduced with our static site support, but not specific to static sites. + +### Security + +- Server-side prototype pollution (CWE-1321) via dot-notation paths. `apos.util.set()` and `apos.util.get()` now refuse to traverse `__proto__`, `constructor` and `prototype` path segments. Previously an authenticated editor could send a PATCH REST API request whose patch operators (for example `$pullAll` with a key of `__proto__.publicApiProjection`) wrote to `Object.prototype`. A polluted `publicApiProjection` defeated the `publicApiCheck()` authorization gate on piece-type REST endpoints for subsequent unauthenticated requests, for the lifetime of the Node.js process. All users should update. Thanks to [tonghuaroot](https://github.com/tonghuaroot), [H3xV0rT3x](https://github.com/H3xV0rT3x), and [5h1kh4r](https://github.com/5h1kh4r) for reporting the vulnerability. +- When `@apostrophecms/file` pretty URLs are enabled (`prettyUrls: true`), the upstream request used to serve the file is no longer built from the incoming `Host` header. The self-request is now resolved against the site's configured `baseUrl` (via `req.baseUrl`), falling back to the request host only when no `baseUrl` is configured. This closes a server-side request forgery (SSRF) vector in which the `Host` header could steer the proxied fetch at another host. The real-world risk was low: the path is constrained to an existing attachment's `/uploads/attachments/<cuid>-<slug>.<ext>`, and cuids are unique and immutable, so any reachable content was already public via the front door. Thanks to [EchoSkorJjj](https://github.com/EchoSkorJjj) for reporting the issue. + ## 4.30.0 ### Adds @@ -21,7 +47,7 @@ - **XSS via full name field:** A malicious full name containing HTML was executed in the page title tooltip in the admin bar, posing an XSS risk to other users. All multi-user projects should update promptly. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting. - **XSS via image widget link URL:** Users with editing privileges could trigger arbitrary JavaScript via a `javascript:` URL in the image widget's link URL field. A migration is included to strip any such URLs already in the database. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting. - **SSRF via rich text HTML import:** The rich text widget's HTML import feature no longer fetches images from arbitrary hosts, which could be used to probe internal networks or exfiltrate internal images. Configure `imageImportAllowedHostnames` on `@apostrophecms/rich-text-widget` to opt in. Thanks to [Yiğit Şengezer](https://github.com/yigitsengezer) and [Sainithin0309](https://github.com/Sainithin0309) for reporting. -- **the xmp tag could be used to pass forbidden markup through sanitize-html**, even when xmp itself. This was fixed in `sanitize-html` and the dependency was bumped. Thanks to [Vincenzo Turturro](https://github.com/sushi-gif) for reporting the vulnerability. +- **the xmp tag could be used to pass forbidden markup through sanitize-html**, even when xmp itself. This was fixed in `sanitize-html` and the dependency was bumped. Thanks to [Vincenzo Turturro](https://github.com/sushi-gif) for reporting the vulnerability. - **the `linkHref` field of image widgets was an XSS vulnerability** because it did not use the `url` field type. This means that a user with editing privileges could potentially carry out XSS. In addition, we have updated the `launder` module to sanitize URLs more robustly for the `url` field type, and bumped that dependency. Also, a database migration is included to clean any XSS attacks that could be present in existing links. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting the issue. ### Accessibility @@ -33,7 +59,6 @@ - Fixed `.apos-sr-only` so screen-reader-only content is correctly exposed to the accessibility tree. - Icon-only context-utility buttons in the admin bar tray (e.g. the global settings cog) now expose their action via `aria-label`. - ## 4.29.0 (2026-04-15) ### Adds diff --git a/packages/apostrophe/package.json b/packages/apostrophe/package.json index 94c83a7382..f798fa9586 100644 --- a/packages/apostrophe/package.json +++ b/packages/apostrophe/package.json @@ -1,6 +1,6 @@ { "name": "apostrophe", - "version": "4.30.0", + "version": "4.31.0", "description": "The Apostrophe Content Management System.", "main": "index.js", "scripts": { diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 517d506fb7..eeedcc6b32 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 3.7.0 (2026-06-10) + +### Adds + +- `apos create` is now an interactive guided installer (it delegates to `create-apostrophe`). The `<shortname>` positional argument and the `--starter` and `--mongodb-uri` options have been removed - project name, starter kit, and database are now chosen through prompts. For scripted installs, use `npm create apostrophe@latest -- --unattended` instead. + ## 3.6.1 ### Security diff --git a/packages/cli/package.json b/packages/cli/package.json index 43e45f479c..902efdab6b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@apostrophecms/cli", - "version": "3.6.1", + "version": "3.7.0", "description": "Commandline generator and configurator for Apostrophe CMS", "main": "bin/apostrophe", "scripts": { diff --git a/packages/import-export/CHANGELOG.md b/packages/import-export/CHANGELOG.md index 0e54855eaa..846e95f7d5 100644 --- a/packages/import-export/CHANGELOG.md +++ b/packages/import-export/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 3.6.1 (2026-06-10) + +### Changes + +- 04d3053: Debug logging is now disabled by default and can be enabled by setting the `debug: true` option on the `@apostrophecms/import-export` module, or by setting the `APOS_DEBUG_IMPORT_EXPORT=1` environment variable. + ## 3.6.0 ### Changes diff --git a/packages/import-export/package.json b/packages/import-export/package.json index a3466d74ad..91069aa861 100644 --- a/packages/import-export/package.json +++ b/packages/import-export/package.json @@ -1,6 +1,6 @@ { "name": "@apostrophecms/import-export", - "version": "3.6.0", + "version": "3.6.1", "description": "Import Export Documents for ApostropheCMS", "main": "index.js", "scripts": { diff --git a/packages/redirect/CHANGELOG.md b/packages/redirect/CHANGELOG.md index fa1ece991c..df79b182f7 100644 --- a/packages/redirect/CHANGELOG.md +++ b/packages/redirect/CHANGELOG.md @@ -1,35 +1,41 @@ # Changelog +## 1.6.0 (2026-06-10) + +### Fixes + +- 958d162: Prevent infinite redirects to external URLs + ## 1.5.0 (2025-11-25) -* Support for wildcards. See the README for more information. +- Support for wildcards. See the README for more information. ## 1.4.3 (2025-09-03) -* Bug fix: UTF8 URLs now match properly. For instance, redirects containing Thai characters work as expected. Editors can paste them naturally (without hand-escaping them first) and the redirect module will correctly decode the URL received from Express before attempting to match it to a redirect. +- Bug fix: UTF8 URLs now match properly. For instance, redirects containing Thai characters work as expected. Editors can paste them naturally (without hand-escaping them first) and the redirect module will correctly decode the URL received from Express before attempting to match it to a redirect. ## 1.4.2 (2024-10-03) -* Updates translations strings +- Updates translations strings ## 1.4.1 (2024-03-20) -* Bug fix to properly migrate older redirects missing a `targetLocale` property, and to tolerate situations where this property is irrelevant or makes reference to a locale that no longer exists in the system. -* Fixes permanent redirects (301) being 302 because `statusCode` of the redirects were never fetched. -* README and package description updated. +- Bug fix to properly migrate older redirects missing a `targetLocale` property, and to tolerate situations where this property is irrelevant or makes reference to a locale that no longer exists in the system. +- Fixes permanent redirects (301) being 302 because `statusCode` of the redirects were never fetched. +- README and package description updated. ## 1.4.0 (2024-02-23) Several fixes and improvements contributed by Stéphane Maccari of Michelin: -* Add a way to modify the target url before doing the redirection -* Irrelevant SEO fields are properly removed from redirect pieces -* The `ignoreQueryString` field is honored properly -* Redirects to internal pages are saved properly -* Admins adding redirects may now elect to pass on the query string as part of the redirect -* `before` option added, giving the option of running the middleware earlier, e.g. before `@apostrophecms/global` -* Performance enhancement: skip the redirect check for API URLs like `/api/v1/...`. This can be -overridden using the `skip` option +- Add a way to modify the target url before doing the redirection +- Irrelevant SEO fields are properly removed from redirect pieces +- The `ignoreQueryString` field is honored properly +- Redirects to internal pages are saved properly +- Admins adding redirects may now elect to pass on the query string as part of the redirect +- `before` option added, giving the option of running the middleware earlier, e.g. before `@apostrophecms/global` +- Performance enhancement: skip the redirect check for API URLs like `/api/v1/...`. This can be + overridden using the `skip` option Many thanks for this contribution. @@ -59,11 +65,11 @@ Many thanks for this contribution. - Adds Spanish (`es`) localization to static text. Thanks to [Eugenio Gonzalez](https://github.com/egonzalezg9) for the contribution. - Adds Slovak (`sk`) locale strings for static text. Thanks to [Michael Huna](https://github.com/Miselrkba) for the contribution. - ## 1.0.1 (2021-08-26) - Localization is inappropriate for redirects since it's necessary to be able to redirect from any URL. Previously `autopublish: true` was used by the module, but `localize: false` is more appropriate as it eliminates multiple locale versions altogether. A migration has been added to take care of existing redirects in this transition. - Fixes README code examples for the `withType` and `statusCode` options. ## 1.0.0 + - Initial port from Apostrophe 2.0 diff --git a/packages/redirect/package.json b/packages/redirect/package.json index ad57aa0c7c..6648868e76 100644 --- a/packages/redirect/package.json +++ b/packages/redirect/package.json @@ -1,6 +1,6 @@ { "name": "@apostrophecms/redirect", - "version": "1.5.0", + "version": "1.6.0", "description": "Manage redirects for apostropheCMS", "main": "index.js", "scripts": { diff --git a/packages/sanitize-html/CHANGELOG.md b/packages/sanitize-html/CHANGELOG.md index b5663fa2b9..9660ac6753 100644 --- a/packages/sanitize-html/CHANGELOG.md +++ b/packages/sanitize-html/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2.17.5 (2026-06-10) + +### Security + +- Added a number of new attributes to be protected against unsafe URLs, e.g. `javascript:` and similar. None of these are used in the default configuration of `sanitize-html` or `apostrophe` or likely to be used there, and some attributes, like an `action` for a `form`, are inherently unsafe to allow if XSS protection is your goal. Nevertheless it makes sense to block certain URL types where they are not appropriate. Some attributes are not supported at all by modern browsers but are included for completeness. Thanks to [crattack](https://github.com/crattack) for reporting the vulnerability. +- Address a potential vulnerability when nonTextTags is configured in a nonstandard way. While it is never a good idea to remove known non-text tags from the standard list e.g. script, styles, etc., this change ensures that doing so does not result in nested tags being passed through without sanitization when they are not expressly allowed. (ApostropheCMS would never trigger this situation.) Thanks to [Dipanshu singh](https://github.com/Dipanshusinghh) for pointing out the issue and contributing the fix. + ## 2.17.4 ### Changes diff --git a/packages/sanitize-html/package.json b/packages/sanitize-html/package.json index a73117759d..656858be83 100644 --- a/packages/sanitize-html/package.json +++ b/packages/sanitize-html/package.json @@ -1,6 +1,6 @@ { "name": "sanitize-html", - "version": "2.17.4", + "version": "2.17.5", "description": "Clean up user-submitted HTML, preserving allowlisted elements and allowlisted attributes on a per-element basis", "sideEffects": false, "main": "index.js", diff --git a/packages/seo/CHANGELOG.md b/packages/seo/CHANGELOG.md index 1beb67cbd6..9079b96a8a 100644 --- a/packages/seo/CHANGELOG.md +++ b/packages/seo/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 1.5.0 (2026-06-10) + +### Adds + +- 958d162: Removes unimplemented hreflang output; use @apostrophecms/sitemap for hreflang support + +### Changes + +- 958d162: Removes the `seoSiteCanonicalUrl` field from global settings. The base URL is now derived automatically from `APOS_BASE_URL` or the `baseUrl` option. The value remains available at `req.data.global.seoSiteCanonicalUrl` for backwards compatibility. + +### Security + +- The Google Analytics tracking ID (`seoGoogleTrackingId`) and Google Tag Manager ID (`seoGoogleTagManager`) global SEO fields were interpolated directly into the bodies of inline `<script>` tags without escaping. Any user permitted to edit the global document, including editors and contributors (if their submission were approved), could set these fields to a value that broke out of the surrounding script and executed arbitrary JavaScript for every visitor on every page (stored XSS). These values are now emitted as escaped `json` nodes, matching the JSON-LD handling, so they can no longer terminate the `<script>` element or escape the string literal they sit in. All projects using `@apostrophecms/seo` with untrusted editors should upgrade promptly to close this vulnerability. Thanks to [H3xV0rT3x](https://github.com/H3xV0rT3x) and [hibrian827](https://github.com/hibrian827) for reporting the issue. + ## 1.4.2 ### Security diff --git a/packages/seo/package.json b/packages/seo/package.json index cc4ccbab99..06a35efd7b 100644 --- a/packages/seo/package.json +++ b/packages/seo/package.json @@ -1,6 +1,6 @@ { "name": "@apostrophecms/seo", - "version": "1.4.2", + "version": "1.5.0", "description": "SEO Tools for ApostropheCMS", "main": "index.js", "scripts": { From 5094022d1cff80be21a9adc82f6fa4457100a78e Mon Sep 17 00:00:00 2001 From: Robert Means <robert@apostrophecms.com> Date: Wed, 10 Jun 2026 15:07:46 -0400 Subject: [PATCH 33/39] Hotfix cli links (#5469) * Fix broken link * Add changeset --- .changeset/mighty-wasps-train.md | 5 +++++ packages/create-apostrophe/src/ui/links.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/mighty-wasps-train.md diff --git a/.changeset/mighty-wasps-train.md b/.changeset/mighty-wasps-train.md new file mode 100644 index 0000000000..c8366935ef --- /dev/null +++ b/.changeset/mighty-wasps-train.md @@ -0,0 +1,5 @@ +--- +"create-apostrophe": patch +--- + +Fixes broken link diff --git a/packages/create-apostrophe/src/ui/links.js b/packages/create-apostrophe/src/ui/links.js index 43cd409af6..56efac4aff 100644 --- a/packages/create-apostrophe/src/ui/links.js +++ b/packages/create-apostrophe/src/ui/links.js @@ -18,7 +18,7 @@ const KIT_GUIDES = Object.freeze({ 'apostrophe-astro-demo': 'https://apostrophecms.com/docs/guide/astro-demo-overview.html', 'apostrophe-astro-demo-data': 'https://apostrophecms.com/docs/guide/astro-demo-overview.html', 'apostrophe-essentials': 'https://apostrophecms.com/docs/guide/apostrophe-standalone-essentials-overview.html', - 'apostrophe-demo': 'https://apostrophecms.com/docs/guides/apostrophe-demo-overview.html', + 'apostrophe-demo': 'https://apostrophecms.com/docs/guide/apostrophe-demo-overview.html', 'apostrophe-demo-data': 'https://apostrophecms.com/docs/guide/apostrophe-demo-overview.html' }); From 7a29be12c7aff354cadf49e7440a51b96a294dcb Mon Sep 17 00:00:00 2001 From: Jinka Manohar <145598597+Manohar2503@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:53:44 +0530 Subject: [PATCH 34/39] Fix asset URLs when a site prefix is configured (#5448) --- .../modules/@apostrophecms/asset/index.js | 5 ++-- packages/apostrophe/test/assets.js | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/apostrophe/modules/@apostrophecms/asset/index.js b/packages/apostrophe/modules/@apostrophecms/asset/index.js index da124ba485..fdb556584f 100644 --- a/packages/apostrophe/modules/@apostrophecms/asset/index.js +++ b/packages/apostrophe/modules/@apostrophecms/asset/index.js @@ -845,16 +845,17 @@ module.exports = { }, getAssetBaseUrl() { const namespace = self.getNamespace(); + const prefix = self.apos.prefix || ''; if (self.isProductionMode()) { const releaseId = self.getReleaseId(); const releaseDir = `/apos-frontend/releases/${releaseId}/${namespace}`; if (process.env.APOS_UPLOADFS_ASSETS) { return `${self.uploadfs.getUrl()}${releaseDir}`; } else { - return releaseDir; + return `${prefix}${releaseDir}`; } } - return `/apos-frontend/${namespace}`; + return `${prefix}/apos-frontend/${namespace}`; }, getCacheBasePath() { return process.env.APOS_ASSET_CACHE || diff --git a/packages/apostrophe/test/assets.js b/packages/apostrophe/test/assets.js index afe6538656..05cccea71c 100644 --- a/packages/apostrophe/test/assets.js +++ b/packages/apostrophe/test/assets.js @@ -171,6 +171,29 @@ describe('Assets', function() { this.timeout(5 * 60 * 1000); + it('should include the site prefix in asset URLs', async function() { + let prefixApos; + + try { + prefixApos = await t.create({ + root: module, + prefix: '/apos' + }); + + assert.equal( + prefixApos.asset.getAssetBaseUrl(), + '/apos/apos-frontend/default' + ); + + assert.equal( + prefixApos.asset.url('/modules/foo/bar.js'), + '/apos/apos-frontend/default/modules/foo/bar.js' + ); + } finally { + await t.destroy(prefixApos); + } + }); + it('should exist on the apos object', async function() { apos = await t.create({ root: module, From 56a844a92cf2b4d0caf1f7fe58867c98b0bd6bbf Mon Sep 17 00:00:00 2001 From: Tom Boutell <tom@apostrophecms.com> Date: Thu, 11 Jun 2026 14:32:15 -0400 Subject: [PATCH 35/39] Mergeup latest to main (#5473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Latest merge prerelease 2026 05 (#5404) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> (cherry picked from commit 9f458b5d9544d33239a0971b0050fff8bffe028a) * Bump CLI dependencies (#5383) (cherry picked from commit a5e1a4a35d01e09cbfd2c503370c95a8c2490867) * Native browser shortcuts work again (#5384) (cherry picked from commit b9b32bdd446add927c75d85e44d0a26814ba62b0) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments (cherry picked from commit 08845c5f23d95ead552ab9743a878daf997793a1) * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> (cherry picked from commit d45e27f6d66cd25fde0cf9c7199ee9d014fa1518) * merge back the thanks (#5388) (cherry picked from commit f3501f4de0448d4d0f8a01a1db4dc2fcf52c6218) * ignore inline table array as draggable ui for windows (#5392) (cherry picked from commit b360b05e8d4e479a36fcae45a5a6dbf820b8fba3) * Layout focus orchestration (#5393) (cherry picked from commit 77a2968206b0208883ff45de37feb43ba0b5d5b1) * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures (cherry picked from commit 008417fa9814dba4899b53b9394778dbd46ccf3c) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed (cherry picked from commit e9b3bac692315414da47fe05202e8854dd31d7ee) * Layout editable gap (#5397) (cherry picked from commit bc8f7bed381d4462a082976dd92b8d7f28180a95) * a11y fixes (#5401) (cherry picked from commit 2e2f3b4b46301fc81c46200f837e794b247a9aef) * clarifications (#5403) (cherry picked from commit 13f2c69f219a47f82972d8fa9036deddd84223dc) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Latest security merge (#5407) * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * Merge commit from fork * Merge commit from fork * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * release only (changelogs formatted) (#5408) * allow oembetter to be released (#5412) * release oembetter 1.2.0 (#5413) * release oembetter 1.2.0 * left commit * [latest[ PRO-9441: modal focus trap * [latest] Fix import-export noise * [latest] A11y fixes part 3 * Fix initial focus trap issue, introduced with recent changes (#5427) * merge main to latest (#5460) * Latest security q2 (#5463) * Bump CLI dependencies (#5383) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> * Native browser shortcuts work again (#5384) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> * merge back the thanks (#5388) * Postgres (#5365) * postgres experimental WIP * astonishingly, all mocha tests of apostrophe pass with this * mocha tests pass, actual sites work * lint clean * listDatabases support, but changes are coming * wip * dump and restore updates * backpressure, adequate handling of ObjectId for our needs (becomes its hex representation) * mild performance optimization * profiling * testing issue resolved * refactored to db-connect module, introduced sqlite adapter * sqlite WIP * debugging * programmatic API for dump/restore/copy dbs * linting, documentation * MIT license * text ranking is more accurate, documentation is more complete * good full text search for sqlite * updates for compatibility with the rest of the public and private modules, plus a few fixes to genuinely ambiguous tests * requirements found by testing private modules * fixes from full cypress run * eslint passing * restore permissions * maximize atomicity * bug fixes * * exit properly when asset tests fail * "npm test" tests all three adapters * ignore claude-tools in eslint * postgres and sqlite-inclusive ci matrix attempt * clean up logs * We hit github's limit on total configurations because every package gets its own matrix. Solve that with grouping: * apostrophe core * All regular ecosystem packages other than core * non-database-requiring packges * mongodb-specific packages This will probably speed it up too because it won't have to spin up a container a bazillion times. * hardened the asset tests, made them less timing sensitive, fixed a bad commit resulting from the way they dodgily patch themselves without a robust cleanup mechanism * fix a root cause of asset test instability * log mess * implemented missing $size operator * test compatibility * advanced permission uses regex in $in * regex in $in * .db() should not make false promises in plain postgres mode, it should fail * ability to specify a default adapter * obsolete file * put escapeHost back where it belongs * dead code removal, test cleanup * emulate-mongo-3-driver only needed in db-connect * no claude logs in repo (tools are welcome) * * shared aggregation implementation, other shared things * optimize $match when it is the first step in aggregation, don't fetch the whole collection 😜 * multipostgres listDatabases() and .db() should return and expect "fully qualified virtual database names," e.g. physical_db_name-schemaname * vanilla postgres should not attempt to use .db() with alternate names in tests * documentation corrections * documentation errors * listDatabases and documentation corrections * more edge cases revealed by latest work from Miro * anchored prefix regexps are optimized documentation improvements * * matchesQuery in the aggregation cursor implementation doesn't throw on unrecognized operators. It should, and it should support the same mongodb operators that the regular find() path does in postgres/sqlite (our official subset), unless there is an extraordinary reason not to. * Similarly, the main query implementation for normal queries should throw on unrecognized operators if it doesn't already. * The dump/restore programmatic APIs in db-connect concern me. These involve returning the entire database as a string, which could exhaust memory. This impacts both utilities and also copyDatabase(). Could these APIs return and expect async iterators instead of strings? * The test "anchored regex on an indexed field uses a btree index search" runs explain on a query that's hardcoded in the test. Instead these SQL based adapters should expose a means to get the SQL for a query, so it can be directly tested. Otherwise this test proves nothing as changes to the adapter accumulate in future. * Why is this test searching for "at least 1" and not exactly 1? it('should find documents with null value', async function() { const docs = await db.collection('test').find({ value: null }).toArray(); // MongoDB matches both null and missing fields with { value: null } expect(docs.length).to.be.at.least(1); }); * What is the maximum size of a db-connect document in the postgres and sqlite adapters? * Update the copyright year in db-connect/LICENSE.md to 2025. * The db-connect README mentions: sqlite://:memory: What happens if you try to use .db('some-name') with that? I think it would be best to just not support throwaway in-memory sqlite databases because I doubt anyone would intentionally store a website in one. * do not swallow dump/restore errors on indexes * cover how to run the utilities * fix detection of source * separate sanitization for index names * regex prefix safety * pnpm --------- Co-authored-by: Thomas Boutell <boutell@vcs.trox.local> * forgot to include a changeset (#5390) * ignore inline table array as draggable ui for windows (#5392) * Layout focus orchestration (#5393) * Pro 9405 remove hreflang (#5395) * Remove hreflang generation and update README * Add changeset * Changeset update * Pro 9406 base url (#5396) * Removes `seoSiteCanonicalUrl` * Update tests and remove missed log * Change semver level * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures * Pro 9405 remove hreflang (#5395) * Remove hreflang generation and update README * Add changeset * Changeset update * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed * Layout editable gap (#5397) * a11y fixes (#5401) * clarifications (#5403) * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * Merge commit from fork * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * mergeback (#5409) * Latest merge prerelease 2026 05 (#5404) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> (cherry picked from commit 9f458b5d9544d33239a0971b0050fff8bffe028a) * Bump CLI dependencies (#5383) (cherry picked from commit a5e1a4a35d01e09cbfd2c503370c95a8c2490867) * Native browser shortcuts work again (#5384) (cherry picked from commit b9b32bdd446add927c75d85e44d0a26814ba62b0) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments (cherry picked from commit 08845c5f23d95ead552ab9743a878daf997793a1) * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> (cherry picked from commit d45e27f6d66cd25fde0cf9c7199ee9d014fa1518) * merge back the thanks (#5388) (cherry picked from commit f3501f4de0448d4d0f8a01a1db4dc2fcf52c6218) * ignore inline table array as draggable ui for windows (#5392) (cherry picked from commit b360b05e8d4e479a36fcae45a5a6dbf820b8fba3) * Layout focus orchestration (#5393) (cherry picked from commit 77a2968206b0208883ff45de37feb43ba0b5d5b1) * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures (cherry picked from commit 008417fa9814dba4899b53b9394778dbd46ccf3c) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed (cherry picked from commit e9b3bac692315414da47fe05202e8854dd31d7ee) * Layout editable gap (#5397) (cherry picked from commit bc8f7bed381d4462a082976dd92b8d7f28180a95) * a11y fixes (#5401) (cherry picked from commit 2e2f3b4b46301fc81c46200f837e794b247a9aef) * clarifications (#5403) (cherry picked from commit 13f2c69f219a47f82972d8fa9036deddd84223dc) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Latest security merge (#5407) * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * Merge commit from fork * Merge commit from fork * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * release only (changelogs formatted) (#5408) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * mergeback (#5414) * Latest merge prerelease 2026 05 (#5404) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> (cherry picked from commit 9f458b5d9544d33239a0971b0050fff8bffe028a) * Bump CLI dependencies (#5383) (cherry picked from commit a5e1a4a35d01e09cbfd2c503370c95a8c2490867) * Native browser shortcuts work again (#5384) (cherry picked from commit b9b32bdd446add927c75d85e44d0a26814ba62b0) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments (cherry picked from commit 08845c5f23d95ead552ab9743a878daf997793a1) * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> (cherry picked from commit d45e27f6d66cd25fde0cf9c7199ee9d014fa1518) * merge back the thanks (#5388) (cherry picked from commit f3501f4de0448d4d0f8a01a1db4dc2fcf52c6218) * ignore inline table array as draggable ui for windows (#5392) (cherry picked from commit b360b05e8d4e479a36fcae45a5a6dbf820b8fba3) * Layout focus orchestration (#5393) (cherry picked from commit 77a2968206b0208883ff45de37feb43ba0b5d5b1) * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures (cherry picked from commit 008417fa9814dba4899b53b9394778dbd46ccf3c) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed (cherry picked from commit e9b3bac692315414da47fe05202e8854dd31d7ee) * Layout editable gap (#5397) (cherry picked from commit bc8f7bed381d4462a082976dd92b8d7f28180a95) * a11y fixes (#5401) (cherry picked from commit 2e2f3b4b46301fc81c46200f837e794b247a9aef) * clarifications (#5403) (cherry picked from commit 13f2c69f219a47f82972d8fa9036deddd84223dc) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Latest security merge (#5407) * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * Merge commit from fork * Merge commit from fork * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * release only (changelogs formatted) (#5408) * allow oembetter to be released (#5412) * release oembetter 1.2.0 (#5413) * release oembetter 1.2.0 * left commit --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Fix focus trap on the last element in a modal (#5406) * Fix focus trap on the last element in a modal * Fix trap escaping edge cases * Fix import-export noise (#5399) * remove noise, switch to utils debug * Fix tests * Introduce debug option * changelog * Fix test sorting issue * A11y fixes part 3 (#5416) * Fix editor modal a11y issues * Fix manager a11y problems * Fix page manager a11y problems * fix media manager a11y issues * fix a11y issues in style editor and user settings * Fix login a11y issues * eliminate a modal issue * Remove bad aria in rich text * Fix wrong aria in layout * changelog * Fix totp a11y issues, doc context state safety * Fix uncaught error - popup blockers/tests * Fix initial focus trap issue, introduced with recent changes (#5426) * PRO-9542: fix the bug that breaks sitemaps for RA (#5433) * PRO-9542: fix the bug that breaks sitemaps for RA * see changeset * Feature/prevent infinite redirects (#5429) * log aposResponse errors * add changeset * prevent infinite redirects to external URLs --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> * add draggable: false support to non-inline array field (#5435) * add draggable: false support to non-inline array field * Add changeset * Make logged-in cookie name configurable via options (#5430) The logged-in cookie name was hardcoded as 'loggedIn' with TODO comments indicating it should be configurable. This is needed for deployments where multiple Apostrophe instances share a domain (e.g., staging and production on subpaths) and need distinct cookie names to avoid conflicts. Changes: - Added 'loggedInCookieName' option to the login module (defaults to 'loggedIn' for backward compatibility) - Replaced all hardcoded references with self.loggedInCookieName - Removed the TODO comments Usage: modules: { '@apostrophecms/login': { options: { loggedInCookieName: 'myAppLoggedIn' } } } Addresses the TODO comments: 'get cookie name from config' Co-authored-by: Vangalla, Rohith <rohith.vangalla@optum.com> * Revert "Make logged-in cookie name configurable via options (#5430)" (#5436) This reverts commit ddcdaa7ff6864cd55d6fef9dfdfca2eaf63a5969. * Feature create-apostrophe (#5425) * Fix new schema areas in existing documents (Astro) (#5434) * Fix orphan or new-in-the-schema areas in external front-ends * Save missing empty areas in the DB, refactor nunjucks path * PRO-6295: jsx as an optional alternative to nunjucks (#5391) * jsx as an optional alternative to nunjucks * eslint, all tests pass * log a useful stack trace on attachment errors! Holy shit! * fix lint * clarify behavior * more tests pass linter * This is just a unit test, but it can't hurt to be thorough & satisfy github-advanced-security Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * true access to the apos object in jsx, per the spec * more test coverage, no code changes * fix watchers --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * no watch in prod (#5439) * Fix new schema areas in existing documents (Astro) Part II (#5440) * Prevent data corruption when stubbing areas for Astro * Fix false positive orphan area warnings * Guard against corrupt area items * Fix raw-text sanitization bypass vulnerability and add regression tests (#5432) * changeset for singh contribution (#5442) * Fix relationship select scrolling issue (#5445) * Fix relationship select scrolling issue * Prevent same scrolling bugs to appear in media manager * jsx changeset (#5446) * Ensure install of the project root for astro projects (#5449) * test node 26 (#5450) * test node 26 * support node 26 by bumping the better-sqlite3 version * node 22 requirement * Add link for telemetry policy (#5455) * remove absent options (#5456) * Remove consumed 4.30.0 changesets from main (#5454) * cli links that are correct, or will be post publish (#5458) * release db connect to solve chicken and egg problem in cypress-tools (#5459) * Corrects documentation links (#5457) * Corrects documentation links * correct `guides` -> `guide` --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Thomas Boutell <boutell@vcs.trox.local> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> Co-authored-by: RohithVangalla1 <reachrohithv@gmail.com> Co-authored-by: Vangalla, Rohith <rohith.vangalla@optum.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Dipanshu singh <161134993+Dipanshusinghh@users.noreply.github.com> * Latest security q2 (#5464) * Bump CLI dependencies (#5383) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> * Native browser shortcuts work again (#5384) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> * merge back the thanks (#5388) * Postgres (#5365) * postgres experimental WIP * astonishingly, all mocha tests of apostrophe pass with this * mocha tests pass, actual sites work * lint clean * listDatabases support, but changes are coming * wip * dump and restore updates * backpressure, adequate handling of ObjectId for our needs (becomes its hex representation) * mild performance optimization * profiling * testing issue resolved * refactored to db-connect module, introduced sqlite adapter * sqlite WIP * debugging * programmatic API for dump/restore/copy dbs * linting, documentation * MIT license * text ranking is more accurate, documentation is more complete * good full text search for sqlite * updates for compatibility with the rest of the public and private modules, plus a few fixes to genuinely ambiguous tests * requirements found by testing private modules * fixes from full cypress run * eslint passing * restore permissions * maximize atomicity * bug fixes * * exit properly when asset tests fail * "npm test" tests all three adapters * ignore claude-tools in eslint * postgres and sqlite-inclusive ci matrix attempt * clean up logs * We hit github's limit on total configurations because every package gets its own matrix. Solve that with grouping: * apostrophe core * All regular ecosystem packages other than core * non-database-requiring packges * mongodb-specific packages This will probably speed it up too because it won't have to spin up a container a bazillion times. * hardened the asset tests, made them less timing sensitive, fixed a bad commit resulting from the way they dodgily patch themselves without a robust cleanup mechanism * fix a root cause of asset test instability * log mess * implemented missing $size operator * test compatibility * advanced permission uses regex in $in * regex in $in * .db() should not make false promises in plain postgres mode, it should fail * ability to specify a default adapter * obsolete file * put escapeHost back where it belongs * dead code removal, test cleanup * emulate-mongo-3-driver only needed in db-connect * no claude logs in repo (tools are welcome) * * shared aggregation implementation, other shared things * optimize $match when it is the first step in aggregation, don't fetch the whole collection 😜 * multipostgres listDatabases() and .db() should return and expect "fully qualified virtual database names," e.g. physical_db_name-schemaname * vanilla postgres should not attempt to use .db() with alternate names in tests * documentation corrections * documentation errors * listDatabases and documentation corrections * more edge cases revealed by latest work from Miro * anchored prefix regexps are optimized documentation improvements * * matchesQuery in the aggregation cursor implementation doesn't throw on unrecognized operators. It should, and it should support the same mongodb operators that the regular find() path does in postgres/sqlite (our official subset), unless there is an extraordinary reason not to. * Similarly, the main query implementation for normal queries should throw on unrecognized operators if it doesn't already. * The dump/restore programmatic APIs in db-connect concern me. These involve returning the entire database as a string, which could exhaust memory. This impacts both utilities and also copyDatabase(). Could these APIs return and expect async iterators instead of strings? * The test "anchored regex on an indexed field uses a btree index search" runs explain on a query that's hardcoded in the test. Instead these SQL based adapters should expose a means to get the SQL for a query, so it can be directly tested. Otherwise this test proves nothing as changes to the adapter accumulate in future. * Why is this test searching for "at least 1" and not exactly 1? it('should find documents with null value', async function() { const docs = await db.collection('test').find({ value: null }).toArray(); // MongoDB matches both null and missing fields with { value: null } expect(docs.length).to.be.at.least(1); }); * What is the maximum size of a db-connect document in the postgres and sqlite adapters? * Update the copyright year in db-connect/LICENSE.md to 2025. * The db-connect README mentions: sqlite://:memory: What happens if you try to use .db('some-name') with that? I think it would be best to just not support throwaway in-memory sqlite databases because I doubt anyone would intentionally store a website in one. * do not swallow dump/restore errors on indexes * cover how to run the utilities * fix detection of source * separate sanitization for index names * regex prefix safety * pnpm --------- Co-authored-by: Thomas Boutell <boutell@vcs.trox.local> * forgot to include a changeset (#5390) * ignore inline table array as draggable ui for windows (#5392) * Layout focus orchestration (#5393) * Pro 9405 remove hreflang (#5395) * Remove hreflang generation and update README * Add changeset * Changeset update * Pro 9406 base url (#5396) * Removes `seoSiteCanonicalUrl` * Update tests and remove missed log * Change semver level * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures * Pro 9405 remove hreflang (#5395) * Remove hreflang generation and update README * Add changeset * Changeset update * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed * Layout editable gap (#5397) * a11y fixes (#5401) * clarifications (#5403) * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * Merge commit from fork * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * mergeback (#5409) * Latest merge prerelease 2026 05 (#5404) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> (cherry picked from commit 9f458b5d9544d33239a0971b0050fff8bffe028a) * Bump CLI dependencies (#5383) (cherry picked from commit a5e1a4a35d01e09cbfd2c503370c95a8c2490867) * Native browser shortcuts work again (#5384) (cherry picked from commit b9b32bdd446add927c75d85e44d0a26814ba62b0) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments (cherry picked from commit 08845c5f23d95ead552ab9743a878daf997793a1) * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> (cherry picked from commit d45e27f6d66cd25fde0cf9c7199ee9d014fa1518) * merge back the thanks (#5388) (cherry picked from commit f3501f4de0448d4d0f8a01a1db4dc2fcf52c6218) * ignore inline table array as draggable ui for windows (#5392) (cherry picked from commit b360b05e8d4e479a36fcae45a5a6dbf820b8fba3) * Layout focus orchestration (#5393) (cherry picked from commit 77a2968206b0208883ff45de37feb43ba0b5d5b1) * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures (cherry picked from commit 008417fa9814dba4899b53b9394778dbd46ccf3c) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed (cherry picked from commit e9b3bac692315414da47fe05202e8854dd31d7ee) * Layout editable gap (#5397) (cherry picked from commit bc8f7bed381d4462a082976dd92b8d7f28180a95) * a11y fixes (#5401) (cherry picked from commit 2e2f3b4b46301fc81c46200f837e794b247a9aef) * clarifications (#5403) (cherry picked from commit 13f2c69f219a47f82972d8fa9036deddd84223dc) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Latest security merge (#5407) * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * Merge commit from fork * Merge commit from fork * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * release only (changelogs formatted) (#5408) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * mergeback (#5414) * Latest merge prerelease 2026 05 (#5404) * Fix choices IDs (#5379) * Fix choices IDs * Cleanup dots from ID values * Update changelog Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Robert Means <robert@apostrophecms.com> (cherry picked from commit 9f458b5d9544d33239a0971b0050fff8bffe028a) * Bump CLI dependencies (#5383) (cherry picked from commit a5e1a4a35d01e09cbfd2c503370c95a8c2490867) * Native browser shortcuts work again (#5384) (cherry picked from commit b9b32bdd446add927c75d85e44d0a26814ba62b0) * Pro 8838 charset (#5385) * Removes encoding option and comments hardcoded encoding meta * add changeset * Response to first comments (cherry picked from commit 08845c5f23d95ead552ab9743a878daf997793a1) * Log aposResponse errors (#5386) * log aposResponse errors * add changeset --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> (cherry picked from commit d45e27f6d66cd25fde0cf9c7199ee9d014fa1518) * merge back the thanks (#5388) (cherry picked from commit f3501f4de0448d4d0f8a01a1db4dc2fcf52c6218) * ignore inline table array as draggable ui for windows (#5392) (cherry picked from commit b360b05e8d4e479a36fcae45a5a6dbf820b8fba3) * Layout focus orchestration (#5393) (cherry picked from commit 77a2968206b0208883ff45de37feb43ba0b5d5b1) * Bump dependencies (#5398) * Bump dependencies * Fix missing test await resulting in random failures (cherry picked from commit 008417fa9814dba4899b53b9394778dbd46ccf3c) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) (#5400) * PRO-9467: remove defunct and nonpublic oembed providers and improve developer control to ensure security in the future (no risk exists today) * allow newer twitter domain * infogr.am still around * facebook no longer does oembed (cherry picked from commit e9b3bac692315414da47fe05202e8854dd31d7ee) * Layout editable gap (#5397) (cherry picked from commit bc8f7bed381d4462a082976dd92b8d7f28180a95) * a11y fixes (#5401) (cherry picked from commit 2e2f3b4b46301fc81c46200f837e794b247a9aef) * clarifications (#5403) (cherry picked from commit 13f2c69f219a47f82972d8fa9036deddd84223dc) --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Latest security merge (#5407) * Merge commit from fork * Fixed arbitrary image URL import vulnerability in rich text impport API * additional reporter * Merge commit from fork * Merge commit from fork * Merge commit from fork * secure the link URL field of image widgets * credit * Merge commit from fork * fix xmp tag vulnerability * thanks * Merge commit from fork * Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be updated promptly to close this vulnerability. * changeset * release only (changelogs formatted) (#5408) * allow oembetter to be released (#5412) * release oembetter 1.2.0 (#5413) * release oembetter 1.2.0 * left commit --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> * Fix focus trap on the last element in a modal (#5406) * Fix focus trap on the last element in a modal * Fix trap escaping edge cases * Fix import-export noise (#5399) * remove noise, switch to utils debug * Fix tests * Introduce debug option * changelog * Fix test sorting issue * A11y fixes part 3 (#5416) * Fix editor modal a11y issues * Fix manager a11y problems * Fix page manager a11y problems * fix media manager a11y issues * fix a11y issues in style editor and user settings * Fix login a11y issues * eliminate a modal issue * Remove bad aria in rich text * Fix wrong aria in layout * changelog * Fix totp a11y issues, doc context state safety * Fix uncaught error - popup blockers/tests * Fix initial focus trap issue, introduced with recent changes (#5426) * PRO-9542: fix the bug that breaks sitemaps for RA (#5433) * PRO-9542: fix the bug that breaks sitemaps for RA * see changeset * Feature/prevent infinite redirects (#5429) * log aposResponse errors * add changeset * prevent infinite redirects to external URLs --------- Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> * add draggable: false support to non-inline array field (#5435) * add draggable: false support to non-inline array field * Add changeset * Make logged-in cookie name configurable via options (#5430) The logged-in cookie name was hardcoded as 'loggedIn' with TODO comments indicating it should be configurable. This is needed for deployments where multiple Apostrophe instances share a domain (e.g., staging and production on subpaths) and need distinct cookie names to avoid conflicts. Changes: - Added 'loggedInCookieName' option to the login module (defaults to 'loggedIn' for backward compatibility) - Replaced all hardcoded references with self.loggedInCookieName - Removed the TODO comments Usage: modules: { '@apostrophecms/login': { options: { loggedInCookieName: 'myAppLoggedIn' } } } Addresses the TODO comments: 'get cookie name from config' Co-authored-by: Vangalla, Rohith <rohith.vangalla@optum.com> * Revert "Make logged-in cookie name configurable via options (#5430)" (#5436) This reverts commit ddcdaa7ff6864cd55d6fef9dfdfca2eaf63a5969. * Feature create-apostrophe (#5425) * Fix new schema areas in existing documents (Astro) (#5434) * Fix orphan or new-in-the-schema areas in external front-ends * Save missing empty areas in the DB, refactor nunjucks path * PRO-6295: jsx as an optional alternative to nunjucks (#5391) * jsx as an optional alternative to nunjucks * eslint, all tests pass * log a useful stack trace on attachment errors! Holy shit! * fix lint * clarify behavior * more tests pass linter * This is just a unit test, but it can't hurt to be thorough & satisfy github-advanced-security Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * true access to the apos object in jsx, per the spec * more test coverage, no code changes * fix watchers --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * no watch in prod (#5439) * Fix new schema areas in existing documents (Astro) Part II (#5440) * Prevent data corruption when stubbing areas for Astro * Fix false positive orphan area warnings * Guard against corrupt area items * Fix raw-text sanitization bypass vulnerability and add regression tests (#5432) * changeset for singh contribution (#5442) * Fix relationship select scrolling issue (#5445) * Fix relationship select scrolling issue * Prevent same scrolling bugs to appear in media manager * jsx changeset (#5446) * Ensure install of the project root for astro projects (#5449) * test node 26 (#5450) * test node 26 * support node 26 by bumping the better-sqlite3 version * node 22 requirement * Add link for telemetry policy (#5455) * remove absent options (#5456) * Remove consumed 4.30.0 changesets from main (#5454) * cli links that are correct, or will be post publish (#5458) * release db connect to solve chicken and egg problem in cypress-tools (#5459) * Corrects documentation links (#5457) * Corrects documentation links * correct `guides` -> `guide` * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Security: added a number of new attributes to be protected against unsafe URLs, e.g. javascript: and similar. None of these are used in the default configuration of sanitize-html or apostrophe or likely to be used there, and some attributes, like an action for a form, are inherently unsafe to allow if XSS protection is your goal. Nevertheless it makes sense to block certain URL types where they are not appropriate. Thanks to [crattack](https://github.com/crattack) for reporting the vulnerability. * changeset * removed duplicate changeset * patch the right module --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Thomas Boutell <boutell@vcs.trox.local> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> Co-authored-by: RohithVangalla1 <reachrohithv@gmail.com> Co-authored-by: Vangalla, Rohith <rohith.vangalla@optum.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Dipanshu singh <161134993+Dipanshusinghh@users.noreply.github.com> * release and changelog edits (#5465) * changelogs * formatting * Hotfix cli links (#5469) * Fix broken link * Add changeset * Create apostrophe hotfix (#5470) * Hotfix cli links (#5469) * Fix broken link * Add changeset * create-apostrophe hotfix --------- Co-authored-by: Robert Means <robert@apostrophecms.com> --------- Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Co-authored-by: Robert Means <robert@apostrophecms.com> Co-authored-by: haroun <1765606+haroun@users.noreply.github.com> Co-authored-by: Harouna Traoré <haroun@users.noreply.github.com> Co-authored-by: Stuart Romanek <stuart@apostrophecms.com> Co-authored-by: Thomas Boutell <boutell@vcs.trox.local> Co-authored-by: RohithVangalla1 <reachrohithv@gmail.com> Co-authored-by: Vangalla, Rohith <rohith.vangalla@optum.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Dipanshu singh <161134993+Dipanshusinghh@users.noreply.github.com> --- packages/create-apostrophe/CHANGELOG.md | 11 +++++++++++ packages/create-apostrophe/package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 packages/create-apostrophe/CHANGELOG.md diff --git a/packages/create-apostrophe/CHANGELOG.md b/packages/create-apostrophe/CHANGELOG.md new file mode 100644 index 0000000000..acc90e9d37 --- /dev/null +++ b/packages/create-apostrophe/CHANGELOG.md @@ -0,0 +1,11 @@ +# create-apostrophe + +## 1.0.1 (2026-06-10) + +### Fixes + +- Fixes broken link + +## 1.0.0 (2026-06-10) + +Initial release. diff --git a/packages/create-apostrophe/package.json b/packages/create-apostrophe/package.json index 4234de6356..b445512571 100644 --- a/packages/create-apostrophe/package.json +++ b/packages/create-apostrophe/package.json @@ -1,6 +1,6 @@ { "name": "create-apostrophe", - "version": "1.0.0", + "version": "1.0.1", "description": "Guided installer for ApostropheCMS — npm create apostrophe@latest", "type": "module", "engines": { From 1937537fb48a6a2f9deda0842f73db2a06facbc5 Mon Sep 17 00:00:00 2001 From: Vansh Parmar <vanshparmar8742@gmail.com> Date: Tue, 16 Jun 2026 14:43:17 +0530 Subject: [PATCH 36/39] fix: treat col as a self-closing tag (#5447) * fix: treat col as a self-closing tag --- .changeset/sixty-hats-kneel.md | 5 +++++ packages/sanitize-html/index.js | 2 +- packages/sanitize-html/test/test.js | 12 ++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 .changeset/sixty-hats-kneel.md diff --git a/.changeset/sixty-hats-kneel.md b/.changeset/sixty-hats-kneel.md new file mode 100644 index 0000000000..6dcd563933 --- /dev/null +++ b/.changeset/sixty-hats-kneel.md @@ -0,0 +1,5 @@ +--- +"apostrophe": patch +--- + +Fix invalid HTML output for <col> elements in sanitize-html (treat void elements correctly) diff --git a/packages/sanitize-html/index.js b/packages/sanitize-html/index.js index 7a616cca60..ed8636bd39 100644 --- a/packages/sanitize-html/index.js +++ b/packages/sanitize-html/index.js @@ -953,7 +953,7 @@ sanitizeHtml.defaults = { 'alt' ], // Lots of these won't come up by default because we don't allow them - selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta' ], + selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta', 'col' ], // URL schemes we permit allowedSchemes: [ 'http', 'https', 'ftp', 'mailto', 'tel' ], allowedSchemesByTag: {}, diff --git a/packages/sanitize-html/test/test.js b/packages/sanitize-html/test/test.js index db3d877f5e..4216ecfb50 100644 --- a/packages/sanitize-html/test/test.js +++ b/packages/sanitize-html/test/test.js @@ -23,6 +23,18 @@ describe('sanitizeHtml', function() { it('should pass through simple, well-formed markup', function() { assert.equal(sanitizeHtml('<div><p>Hello <b>there</b></p></div>'), '<div><p>Hello <b>there</b></p></div>'); }); + it('should preserve col as a self closing tag', function() { + assert.equal( + sanitizeHtml( + '<table><colgroup><col span="2"></colgroup></table>', + { + allowedTags: false, + allowedAttributes: false + } + ), + '<table><colgroup><col span="2" /></colgroup></table>' + ); + }); it('should not pass through any text outside html tag boundary since html tag is found and option is ON', function() { assert.equal(sanitizeHtml('Text before html tag<html><div><p>Hello <b>there</b></p></div></html>Text after html tag!P�X��[<p>paragraph after closing html</p>', { enforceHtmlBoundary: true From 57a01abec558cc6dab78a113f5a6d71572dc2d12 Mon Sep 17 00:00:00 2001 From: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:35:39 +0300 Subject: [PATCH 37/39] Add explicit test for inline configuration collapsing (#5475) * Add explicit test for inline configuration collapsing * Update test reason * Better contract definition --- .../apostrophe/test/config-cascade-merge.js | 73 +++++++++++++++++++ .../test/modules/collision-piece/index.js | 23 ++++++ 2 files changed, 96 insertions(+) create mode 100644 packages/apostrophe/test/config-cascade-merge.js create mode 100644 packages/apostrophe/test/modules/collision-piece/index.js diff --git a/packages/apostrophe/test/config-cascade-merge.js b/packages/apostrophe/test/config-cascade-merge.js new file mode 100644 index 0000000000..a232df4634 --- /dev/null +++ b/packages/apostrophe/test/config-cascade-merge.js @@ -0,0 +1,73 @@ +const t = require('../test-lib/test.js'); +const assert = require('assert'); + +// PRO-9564 +// This test pins an established moog-require contract so it is not "fixed" by +// accident — changing it would silently break existing projects. +// +// Scope: the SAME module configured BOTH via the app.js object passed to +// apostrophe() AND its project-level index.js. (NOT cascade merging across the +// module chain via extends/improvements, which merges field-by-field as usual.) +// +// moog-require collapses the two sources with a shallow `_.defaults` before the +// chain is built, so app.js wins for any top-level section declared in both. +// `options` then gets a second pass that gap-fills from index.js, but no other +// section does — so a cascade like `fields` is NOT merged: an app.js `fields` +// replaces the index.js `fields` wholesale, dropping even unrelated +// project-level fields. The options-only gap-fill is by design. +// +// Example — this test and its fixture: +// app.js modules: { 'collision-piece': { fields: { add: { configField } } } } +// index.js test/modules/collision-piece/index.js -> fields: { add: { projectField } } +// result schema has `configField`; `projectField` is gone, although the two +// fields are unrelated. +// options app.js { alias: ... } still merges with any index.js options +// (gap-fill), unlike `fields`. +// +// Files: collapse in packages/apostrophe/lib/moog-require.js (~`_.defaults( +// definition, projectLevelDefinition)`); cascade compiler that the dropped +// section never reaches in packages/apostrophe/lib/moog.js; project-level +// fixture in test/modules/collision-piece/index.js. + +describe('config-object vs project-level cascade merge (moog-require contract)', function () { + this.timeout(t.timeout); + let apos; + after(async () => { + await t.destroy(apos); + }); + + it('app.js `fields` wins over the project-level file when both declare it', async function () { + apos = await t.create({ + root: module, + modules: { + 'collision-piece': { + extend: '@apostrophecms/piece-type', + options: { alias: 'collisionPiece' }, + fields: { + add: { + configField: { + type: 'string', + label: 'Config Field' + } + }, + group: { + configGroup: { + label: 'Config Group', + fields: [ 'configField' ] + } + } + } + } + } + }); + + const names = apos.collisionPiece.schema.map(f => f.name); + // The app.js (config object) fields win. + assert(names.includes('configField')); + // The project-level file's fields are dropped wholesale — the contract. + assert(!names.includes('projectField')); + // Options are the exception: an index.js-only option is gap-filled in, + // where a project-level field would have been dropped. + assert.strictEqual(apos.collisionPiece.options.fromProjectFile, true); + }); +}); diff --git a/packages/apostrophe/test/modules/collision-piece/index.js b/packages/apostrophe/test/modules/collision-piece/index.js new file mode 100644 index 0000000000..d44d5ee2a4 --- /dev/null +++ b/packages/apostrophe/test/modules/collision-piece/index.js @@ -0,0 +1,23 @@ +// Project-level file for a module that is ALSO configured (with fields) +// via the apostrophe() config object, to reproduce PRO-9564. +module.exports = { + // An index.js-only option: it should survive via the gap-fill pass, unlike + // the project-level fields, which get dropped wholesale. + options: { + fromProjectFile: true + }, + fields: { + add: { + projectField: { + type: 'string', + label: 'Project Field' + } + }, + group: { + projectGroup: { + label: 'Project Group', + fields: [ 'projectField' ] + } + } + } +}; From 334dc611bd3e76cd5f5aac2ee888f9aa1f3ff7b1 Mon Sep 17 00:00:00 2001 From: Robert Means <robert@apostrophecms.com> Date: Wed, 17 Jun 2026 05:34:54 -0400 Subject: [PATCH 38/39] Pro 9442 insensitive redirects (#5479) * First stab at redirects * Add changeset * Response to first review --- .changeset/jolly-zoos-fry.md | 5 + packages/redirect/README.md | 22 +++++ packages/redirect/index.js | 72 +++++++++++++-- packages/redirect/test/index.js | 157 +++++++++++++++++++++++++++++++- 4 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 .changeset/jolly-zoos-fry.md diff --git a/.changeset/jolly-zoos-fry.md b/.changeset/jolly-zoos-fry.md new file mode 100644 index 0000000000..13ef149465 --- /dev/null +++ b/.changeset/jolly-zoos-fry.md @@ -0,0 +1,5 @@ +--- +"@apostrophecms/redirect": minor +--- + +New `caseInsensitive` option. When enabled, "Old URL" values are stored in lowercase and incoming request URLs are matched case-insensitively. A migration lowercases existing redirects when the option is enabled. See the README for details, including a note on the non-reversible nature of this change. diff --git a/packages/redirect/README.md b/packages/redirect/README.md index b3e7f95abc..25907f837d 100644 --- a/packages/redirect/README.md +++ b/packages/redirect/README.md @@ -83,6 +83,28 @@ The default configuration of the `skip` option is: If you wish to skip other patterns, we recommend keeping the default one as it speeds up API calls. +### `caseInsensitive` +*Defaults to `false`* + +By passing `caseInsensitive: true` to your configuration, the "Old URL" of every redirect is matched against incoming request URLs without regard to case. This is useful when migrating from a case-insensitive web server (e.g. IIS), where redirect rules may have been entered with mixed-case slugs such as `/Events` or `/About-Us/Partners`, but inbound traffic (old bookmarks, search engine results, external links) may arrive with any casing, such as `/events` or `/EVENTS`. + +```javascript +// Other modules, then... +'@apostrophecms/redirect': { + options: { + caseInsensitive: true + } +} +``` + +When this option is enabled: + +- New and edited redirects have their "Old URL" stored in lowercase automatically. +- A migration runs to lowercase the "Old URL" of any existing redirects. +- Incoming request URLs are lowercased before being matched against stored redirects (the original casing of the request is still used when substituting a wildcard match into a destination URL). + +⚠️ **This option does not support "mixing" case-sensitive and case-insensitive redirects.** Enabling it lowercases all existing "Old URL" values, and this migration is **not reversible**. If you later disable `caseInsensitive`, your pre-existing redirects will only match lowercase request URLs, since their stored "Old URL" values remain lowercase. If this matters to you, you would need to manually recreate the affected redirects with their original mixed-case "Old URL" values. + ## Usage While logged in as an admin, click the "Redirects" button. A list of redirects appears, initially empty. Add as many redirects as you like. The "from" URL must begin with a `/`. The "to" URL may be anything and need not be on your site. The "description" field is for your own convenience. diff --git a/packages/redirect/index.js b/packages/redirect/index.js index 5560e070e4..dfca1e738c 100644 --- a/packages/redirect/index.js +++ b/packages/redirect/index.js @@ -18,16 +18,23 @@ module.exports = { openGraph: false, // Disables @apostrophecms/open-graph for redirects seoFields: false, // Disables @apostrophecms/seo for redirects skip: [ /\/api\/v1\/.*/ ], - before: null + before: null, + caseInsensitive: false }, init(self) { self.addUnlocalizedMigration(); self.addTargetLocaleMigration(); + self.addCaseInsensitiveMigration(); self.createIndexes(); }, handlers(self) { return { beforeSave: { + normalizeRedirectSlug(req, doc) { + if (self.options.caseInsensitive && typeof doc.redirectSlug === 'string') { + doc.redirectSlug = doc.redirectSlug.toLowerCase(); + } + }, preventInfiniteRedirect(req, doc) { if (doc.urlType === 'external' && doc.redirectSlug === doc.externalUrl) { throw self.apos.error('invalid', req.t('aposRedirect:errorInfiniteRedirect', { @@ -217,21 +224,35 @@ module.exports = { slug = pathOnly; } + // When caseInsensitive is enabled, redirectSlug and + // redirectSlugPrefix are stored in lowercase, so the inbound + // URL must be lowercased for matching purposes. The original + // (cased) pathOnly is preserved separately for use in wildcard + // substitutions. + let matchSlug = slug; + let matchPathOnly = pathOnly; + let matchPrefixes = prefixes; + if (self.options.caseInsensitive) { + matchSlug = slug.toLowerCase(); + matchPathOnly = pathOnly.toLowerCase(); + matchPrefixes = prefixes.map(prefix => prefix.toLowerCase()); + } + // Build query conditions const orConditions = [ - { redirectSlug: slug } + { redirectSlug: matchSlug } ]; - if (pathOnly !== slug) { + if (matchPathOnly !== matchSlug) { orConditions.push({ - redirectSlug: pathOnly + redirectSlug: matchPathOnly }); } // Add wildcard prefix matching orConditions.push({ redirectSlugPrefix: { - $in: prefixes + $in: matchPrefixes } }); @@ -261,7 +282,7 @@ module.exports = { let validMatches = []; for (const redirect of results) { // Exact match - if (redirect.redirectSlug === slug) { + if (redirect.redirectSlug === matchSlug) { redirect.matchType = 'exact'; redirect.matchLength = 0; redirect.wildcardMatch = null; @@ -272,7 +293,7 @@ module.exports = { } // Exact match ignoring query string - if (redirect.redirectSlug === pathOnly && redirect.ignoreQueryString) { + if (redirect.redirectSlug === matchPathOnly && redirect.ignoreQueryString) { redirect.matchType = 'exact'; redirect.matchLength = 0; redirect.wildcardMatch = null; @@ -285,16 +306,22 @@ module.exports = { // Wildcard match if ( redirect.redirectSlugPrefix && - pathOnly.startsWith(redirect.redirectSlugPrefix) + matchPathOnly.startsWith(redirect.redirectSlugPrefix) ) { const wildcardIndex = redirect.redirectSlug.indexOf('*'); const suffixPattern = redirect.redirectSlug.substring(wildcardIndex + 1); + // Used for matching against the (possibly lowercased) stored + // suffix pattern + const matchCapturedPart = matchPathOnly + .substring(redirect.redirectSlugPrefix.length); + // Preserves the original request casing so it can be + // substituted verbatim into external URL wildcards const capturedPart = pathOnly .substring(redirect.redirectSlugPrefix.length); // Check if the URL matches the suffix pattern if (suffixPattern) { - if (capturedPart.endsWith(suffixPattern)) { + if (matchCapturedPart.endsWith(suffixPattern)) { redirect.matchType = 'wildcard'; redirect.matchLength = redirect.redirectSlugPrefix.length; redirect.wildcardMatch = capturedPart @@ -465,6 +492,33 @@ module.exports = { }); }, + addCaseInsensitiveMigration() { + self.apos.migration.add('@apostrophecms/redirect:caseInsensitive', async () => { + if (!self.options.caseInsensitive) { + return; + } + await self.apos.migration.eachDoc({ + type: self.__meta.name + }, async redirect => { + const loweredSlug = redirect.redirectSlug.toLowerCase(); + if (loweredSlug === redirect.redirectSlug) { + return; + } + const $set = { + redirectSlug: loweredSlug + }; + if (redirect.redirectSlugPrefix) { + $set.redirectSlugPrefix = redirect.redirectSlugPrefix.toLowerCase(); + } + await self.apos.doc.db.updateOne({ + _id: redirect._id + }, { + $set + }); + }); + }); + }, + createIndexes() { self.apos.doc.db.createIndex({ redirectSlug: 1 }); self.apos.doc.db.createIndex({ redirectSlugPrefix: 1 }); diff --git a/packages/redirect/test/index.js b/packages/redirect/test/index.js index c1371e232c..6a6211a946 100644 --- a/packages/redirect/test/index.js +++ b/packages/redirect/test/index.js @@ -344,6 +344,158 @@ describe('@apostrophecms/redirect', function () { const redirected = await apos.http.get(`http://localhost:${server.address().port}/manufacturers/mercedes-benz/cl600`); assert.equal(redirected, '<title>cl600</title>\n'); }); + + it('should require exact case by default', async function() { + const req = apos.task.getReq(); + const instance = redirectModule.newInstance(); + await redirectModule.insert(req, { + ...instance, + title: 'mixed case redirect', + urlType: 'external', + redirectSlug: '/Page-1', + externalUrl: `http://localhost:${server.address().port}/page-2` + }); + + try { + await apos.http.get(`http://localhost:${server.address().port}/page-1`); + assert(false, 'should not have matched a differently-cased slug'); + } catch (e) { + // good, should 404 + } + }); +}); + +describe('@apostrophecms/redirect (caseInsensitive option)', function () { + let apos; + let redirectModule; + let server; + + this.timeout(t.timeout); + + after(async function() { + await t.destroy(apos); + }); + + before(async function() { + apos = await t.create({ + root: module, + testModule: true, + modules: getAppConfig({ caseInsensitive: true }) + }); + + redirectModule = apos.modules['@apostrophecms/redirect']; + server = apos.modules['@apostrophecms/express'].server; + await insertPages(apos); + }); + + this.afterEach(async function() { + await apos.doc.db.deleteMany({ type: '@apostrophecms/redirect' }); + }); + + it('should lowercase the redirectSlug on save', async function() { + const req = apos.task.getReq(); + const instance = redirectModule.newInstance(); + const inserted = await redirectModule.insert(req, { + ...instance, + title: 'mixed case redirect', + urlType: 'external', + redirectSlug: '/Page-1', + externalUrl: `http://localhost:${server.address().port}/page-2` + }); + + assert.equal(inserted.redirectSlug, '/page-1'); + }); + + it('should match a mixed-case stored slug against a differently-cased request', async function() { + const req = apos.task.getReq(); + const instance = redirectModule.newInstance(); + await redirectModule.insert(req, { + ...instance, + title: 'mixed case redirect', + urlType: 'external', + redirectSlug: '/Page-1', + externalUrl: `http://localhost:${server.address().port}/page-2` + }); + + const redirected = await apos.http.get(`http://localhost:${server.address().port}/PAGE-1`); + assert.equal(redirected, '<title>page 2</title>\n'); + }); + + it('should match multi-segment mixed-case slugs case-insensitively', async function() { + const req = apos.task.getReq(); + const instance = redirectModule.newInstance(); + await redirectModule.insert(req, { + ...instance, + title: 'multi-segment redirect', + urlType: 'external', + redirectSlug: '/Krescent/About-Us/Partners', + externalUrl: `http://localhost:${server.address().port}/page-2` + }); + + const redirected = await apos.http.get(`http://localhost:${server.address().port}/KRESCENT/About-Us/PARTNERS`); + assert.equal(redirected, '<title>page 2</title>\n'); + }); + + it('should match wildcard prefixes case-insensitively', async function() { + const req = apos.task.getReq(); + const instance = redirectModule.newInstance(); + await redirectModule.insert(req, { + ...instance, + title: 'wildcard redirect', + urlType: 'external', + redirectSlug: '/Manufacturers/*', + externalUrl: '/auto/manufacturers' + }); + + const redirected = await apos.http.get(`http://localhost:${server.address().port}/MANUFACTURERS/bmw/k-1100-lt`); + assert.equal(redirected, '<title>manufacturers</title>\n'); + }); + + it('should match wildcard captures case-insensitively while preserving the request casing in the substitution', async function() { + const req = apos.task.getReq(); + const instance = redirectModule.newInstance(); + await redirectModule.insert(req, { + ...instance, + title: 'wildcard capture redirect', + urlType: 'external', + redirectSlug: '/Manufacturers/Mercedes-Benz/*', + externalUrl: '/auto/manufacturers/mercedes-benz/*' + }); + + const redirected = await apos.http.get(`http://localhost:${server.address().port}/MANUFACTURERS/Mercedes-Benz/cl600`); + assert.equal(redirected, '<title>cl600</title>\n'); + }); + + it('should lowercase existing mixed-case slugs via the migration', async function() { + const migration = apos.migration.migrations.find( + m => m.name === '@apostrophecms/redirect:caseInsensitive' + ); + assert(migration, 'expected the caseInsensitive migration to be registered'); + + await apos.doc.db.insertOne({ + _id: 'legacyRedirect:en:published', + aposDocId: 'legacyRedirect', + aposLocale: 'en:published', + aposMode: 'published', + type: '@apostrophecms/redirect', + title: '/Legacy-Slug', + slug: 'redirect-/Legacy-Slug', + visibility: 'public', + redirectSlug: '/Legacy-Slug', + redirectSlugPrefix: null, + urlType: 'external', + externalUrl: `http://localhost:${server.address().port}/page-2`, + ignoreQueryString: false, + forwardQueryString: false, + statusCode: '302', + targetLocale: null + }); + + await migration.fn(); + + const migrated = await apos.doc.db.findOne({ _id: 'legacyRedirect:en:published' }); + assert.equal(migrated.redirectSlug, '/legacy-slug'); + }); }); async function insertPages(apos) { @@ -379,7 +531,7 @@ async function insertPages(apos) { }); } -function getAppConfig() { +function getAppConfig(redirectOptions = {}) { return { '@apostrophecms/express': { options: { @@ -400,7 +552,8 @@ function getAppConfig() { }, '@apostrophecms/redirect': { options: { - alias: 'redirect' + alias: 'redirect', + ...redirectOptions } }, 'default-page': {}, From 91ecf4731ec5726b14e7967a0e9e71a5eb86ff77 Mon Sep 17 00:00:00 2001 From: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:57:27 +0300 Subject: [PATCH 39/39] Bump undici, fix a leak (#5480) --- .changeset/old-seals-accept.md | 5 +++++ packages/apostrophe-astro/lib/aposResponse.js | 8 ++++++-- packages/apostrophe-astro/package.json | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .changeset/old-seals-accept.md diff --git a/.changeset/old-seals-accept.md b/.changeset/old-seals-accept.md new file mode 100644 index 0000000000..0f0296d090 --- /dev/null +++ b/.changeset/old-seals-accept.md @@ -0,0 +1,5 @@ +--- +"@apostrophecms/apostrophe-astro": minor +--- + +Upgraded the `undici` HTTP client from v6 to v8, which requires Node.js 22.19 or newer, and fixed a connection leak in the Astro proxy where responses that are not streamed on to the browser — redirects (301/302/307/308) and bodyless responses (204/304) — now release their backend response body immediately instead of leaving it for garbage collection, which under load could hold connections open and exhaust the connection pool. diff --git a/packages/apostrophe-astro/lib/aposResponse.js b/packages/apostrophe-astro/lib/aposResponse.js index 0324c0a0ac..946b8786b0 100644 --- a/packages/apostrophe-astro/lib/aposResponse.js +++ b/packages/apostrophe-astro/lib/aposResponse.js @@ -83,8 +83,12 @@ export default async function aposResponse(req) { const { headers, statusCode, ...rest } = res; - // Handle empty responses (status codes that should not have bodies) - if ([204, 304].includes(statusCode)) { + // Statuses whose body we never send to the client: 204/304 carry none, + // and redirects (301/302/307/308) become a fresh Astro redirect built + // from the Location header in aposProxy. Dump the undici body so its + // socket returns to the pool instead of being held open until GC. + if ([204, 304, 301, 302, 307, 308].includes(statusCode)) { + await res.body.dump().catch(() => {}); return new Response(null, { ...rest, status: statusCode, headers: responseHeaders }); } diff --git a/packages/apostrophe-astro/package.json b/packages/apostrophe-astro/package.json index f0b5def0ef..2f705552aa 100644 --- a/packages/apostrophe-astro/package.json +++ b/packages/apostrophe-astro/package.json @@ -20,11 +20,11 @@ "dependencies": { "lodash.deburr": "^4.1.0", "sluggo": "^1.0.0", - "undici": "^7.27.1" + "undici": "^8.5.0" }, "devDependencies": { "esmock": "^2.7.6", "mocha": "^11.7.6", "typescript": "^6.0.3" } -} +} \ No newline at end of file