diff --git a/.changeset/six-socks-design.md b/.changeset/six-socks-design.md
new file mode 100644
index 0000000000..9f10c66f0b
--- /dev/null
+++ b/.changeset/six-socks-design.md
@@ -0,0 +1,19 @@
+---
+"@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
+- Add `peerDependencies` declaring Astro v5, v6, and v7 support.
+- Fix `virtual:apostrophe-config` import in `aposLiteralContentMiddleware.js` to use the generated-file module path.
+- Drop deprecated entryPoint shim from injectRoute calls.
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/MIGRATION.md b/packages/apostrophe-astro/MIGRATION.md
new file mode 100644
index 0000000000..96a90d440c
--- /dev/null
+++ b/packages/apostrophe-astro/MIGRATION.md
@@ -0,0 +1,62 @@
+# 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 for public helpers
+
+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/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 { getAllStaticPaths } from '@apostrophecms/apostrophe-astro/lib/static.js';
+
+// After
+import { 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/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/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..ec2011ca65 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;
@@ -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/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/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/index.js b/packages/apostrophe-astro/helpers/index.js
index a953c3e71e..1fd6d8bfb3 100644
--- a/packages/apostrophe-astro/helpers/index.js
+++ b/packages/apostrophe-astro/helpers/index.js
@@ -1,3 +1,14 @@
-export { getAposHost, isStaticBuild, buildPageUrl, getFilterBaseUrl, aposSetQueryParameter } from './url.js';
-export { slugify } from './slug.js';
-export { aposFetch } from './fetch.js';
+/**
+ * @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, 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/fetch.js b/packages/apostrophe-astro/helpers/server/fetch.js
similarity index 91%
rename from packages/apostrophe-astro/helpers/fetch.js
rename to packages/apostrophe-astro/helpers/server/fetch.js
index 646c8c7cab..eae1466700 100644
--- a/packages/apostrophe-astro/helpers/fetch.js
+++ b/packages/apostrophe-astro/helpers/server/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/...`).
*
@@ -28,7 +28,7 @@ import { getAposHost } from './url.js';
* @example
* ```astro
* ---
- * import { aposFetch } from '@apostrophecms/apostrophe-astro/helpers';
+ * import { aposFetch } from '@apostrophecms/apostrophe-astro/helpers/server';
* const response = await aposFetch('/api/v1/article?perPage=5');
* const data = await response.json();
* ---
@@ -50,3 +50,4 @@ export async function aposFetch(input, init) {
headers
});
}
+
diff --git a/packages/apostrophe-astro/helpers/server/index.js b/packages/apostrophe-astro/helpers/server/index.js
new file mode 100644
index 0000000000..521836b2c5
--- /dev/null
+++ b/packages/apostrophe-astro/helpers/server/index.js
@@ -0,0 +1,14 @@
+/**
+ * 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 } 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/server/url.js b/packages/apostrophe-astro/helpers/server/url.js
new file mode 100644
index 0000000000..d4c2c76ce0
--- /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 process.env.APOS_ASTRO_STATIC_BUILD === '1' || Boolean(config.staticBuild);
+}
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/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/url.js b/packages/apostrophe-astro/helpers/universal/url.js
similarity index 66%
rename from packages/apostrophe-astro/helpers/url.js
rename to packages/apostrophe-astro/helpers/universal/url.js
index e5ab6e1424..5dcab3eb08 100644
--- a/packages/apostrophe-astro/helpers/url.js
+++ b/packages/apostrophe-astro/helpers/universal/url.js
@@ -1,63 +1,6 @@
-import config from 'virtual:apostrophe-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.
+// Mode-aware URL building utilities for Apostrophe piece index pages.
//
-// Static URLs (`@apostrophecms/url` option `static: true`):
+// Static URLs (@apostrophecms/url option `static: true`):
// /articles/page/2
// /articles/categories/insights/page/2
//
@@ -115,7 +58,7 @@ export function getFilterBaseUrl(aposData) {
* @example
* ```astro
* ---
- * import { buildPageUrl } from '@apostrophecms/apostrophe-astro/helpers';
+ * import { buildPageUrl } from '@apostrophecms/apostrophe-astro/helpers/universal';
* const { aposData } = Astro.props;
* ---
* Page 2
@@ -144,7 +87,7 @@ export function buildPageUrl(aposData, pageNum) {
* 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
+ * removed from the query string. Internal Apostrophe parameters
* (`aposRefresh`, `aposMode`, `aposEdit`) are always stripped.
*
* Typically `Astro.url` is passed as the first argument.
@@ -158,7 +101,7 @@ export function buildPageUrl(aposData, pageNum) {
* @example
* ```astro
* ---
- * import { aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers';
+ * import { aposSetQueryParameter } from '@apostrophecms/apostrophe-astro/helpers/universal';
* const next = aposSetQueryParameter(Astro.url, 'page', '2');
* ---
* Page 2
diff --git a/packages/apostrophe-astro/index.js b/packages/apostrophe-astro/index.js
index 2df5dffd99..84335b9472 100644
--- a/packages/apostrophe-astro/index.js
+++ b/packages/apostrophe-astro/index.js
@@ -1,6 +1,8 @@
-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 { fileURLToPath } from 'node:url';
import {
+ setStaticCacheDir,
writeConfigCache,
writeLiteralContent,
writeAttachments,
@@ -133,33 +135,86 @@ 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);
+ // 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
+ // 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(
+ projectRoot,
+ '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,
+ 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' : '')
+ }
+ }
+ }
+ }
});
// Proxy routes are only needed for SSR — in static mode all data
// is fetched at build time via getStaticPaths / aposPageFetch.
@@ -183,23 +238,18 @@ export default function apostropheIntegration(options) {
...(options.proxyRoutes || [])
];
for (const pattern of inject) {
- // duplication of entrypoint needed for Astro 3.x support per
- // https://docs.astro.build/en/guides/upgrade-to/v4/#renamed-entrypoint-integrations-api
injectRoute({
pattern,
- entryPoint: '@apostrophecms/apostrophe-astro/endpoints/aposProxy.js',
entrypoint: '@apostrophecms/apostrophe-astro/endpoints/aposProxy.js'
});
}
// Different pattern from the rest
injectRoute({
pattern: '/[locale]/api/v1/@apostrophecms/area/render-widget',
- entryPoint: '@apostrophecms/apostrophe-astro/endpoints/renderWidget.astro',
entrypoint: '@apostrophecms/apostrophe-astro/endpoints/renderWidget.astro'
});
injectRoute({
pattern: '/api/v1/@apostrophecms/area/render-widget',
- entryPoint: '@apostrophecms/apostrophe-astro/endpoints/renderWidget.astro',
entrypoint: '@apostrophecms/apostrophe-astro/endpoints/renderWidget.astro'
});
},
diff --git a/packages/apostrophe-astro/lib/aposLiteralContentMiddleware.js b/packages/apostrophe-astro/lib/aposLiteralContentMiddleware.js
index 3b362d927d..a5db885389 100644
--- a/packages/apostrophe-astro/lib/aposLiteralContentMiddleware.js
+++ b/packages/apostrophe-astro/lib/aposLiteralContentMiddleware.js
@@ -15,7 +15,7 @@
// is static once the backend is up. A failed fetch is not cached, so it is
// retried on the next request; this gracefully handles any boot racing conditions.
import { defineMiddleware } from 'astro:middleware';
-import config from 'virtual:apostrophe-config';
+import config from 'apostrophe-astro-config/config';
import aposResponse from './aposResponse.js';
const EXTERNAL_FRONT_KEY = process.env.APOS_EXTERNAL_FRONT_KEY;
diff --git a/packages/apostrophe-astro/lib/aposPageFetch.js b/packages/apostrophe-astro/lib/aposPageFetch.js
index 099af16071..5f0fb540ce 100644
--- a/packages/apostrophe-astro/lib/aposPageFetch.js
+++ b/packages/apostrophe-astro/lib/aposPageFetch.js
@@ -1,11 +1,39 @@
+import config from 'apostrophe-astro-config/config';
import aposResponse from './aposResponse.js';
-import aposRequest from './aposRequest.js';
-import config from 'virtual:apostrophe-config';
+import aposRequest, { isAstroPrerenderedRequest } from './aposRequest.js';
-export default async function aposPageFetch(req) {
+/**
+ * Fetch a full Apostrophe page data object for the given Astro request.
+ *
+ * @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