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} 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 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' @@ -66,3 +94,5 @@ export default async function aposPageFetch(req) { } return aposData; } + +export default aposPageFetch; diff --git a/packages/apostrophe-astro/lib/aposRequest.js b/packages/apostrophe-astro/lib/aposRequest.js index 635fcedd43..dc77822fbe 100644 --- a/packages/apostrophe-astro/lib/aposRequest.js +++ b/packages/apostrophe-astro/lib/aposRequest.js @@ -1,17 +1,51 @@ -import config from 'virtual:apostrophe-config'; +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'); + return request; } diff --git a/packages/apostrophe-astro/lib/aposResponse.js b/packages/apostrophe-astro/lib/aposResponse.js index 6e5f02fea8..946b8786b0 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'; @@ -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; } } 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/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'; diff --git a/packages/apostrophe-astro/lib/static.js b/packages/apostrophe-astro/lib/static.js index 24ee0897cc..f844226564 100644 --- a/packages/apostrophe-astro/lib/static.js +++ b/packages/apostrophe-astro/lib/static.js @@ -2,12 +2,62 @@ 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 CONFIG_CACHE = join(CACHE_DIR, '_config.json'); -const ATTACHMENTS_CACHE = join(CACHE_DIR, '_attachments.json'); +// 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. + */ +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(getCacheDir(), '_config.json'); +} + +function getAttachmentsCachePath() { + return join(getCacheDir(), '_attachments.json'); +} + /** * Persist static build configuration to the cache directory. * Cleans previous cache before writing. Called from the @@ -16,9 +66,10 @@ 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)); + const dir = requireCacheDir(); + await rm(dir, { recursive: true, force: true }).catch(() => {}); + await mkdir(dir, { recursive: true }); + await writeFile(getConfigCachePath(), JSON.stringify(staticBuild)); } function authHeaders(key) { @@ -81,7 +132,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) { @@ -162,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(CACHE_DIR, { recursive: true }); + const dir = getCacheDir(); + await mkdir(dir, { recursive: true }); await writeFile( - join(CACHE_DIR, `${cacheKey}.json`), + join(dir, `${cacheKey}.json`), JSON.stringify({ locale: locale || null, literalContent }) ); @@ -186,9 +238,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. @@ -209,7 +261,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 +308,9 @@ export async function getAllStaticPaths(config) { } // Cache deduplicated attachment metadata for the post-build hook - await mkdir(CACHE_DIR, { recursive: true }); + await mkdir(getCacheDir(), { recursive: true }); await writeFile( - ATTACHMENTS_CACHE, + getAttachmentsCachePath(), JSON.stringify({ uploadsUrl, results: [ ...attachmentMap.values() ] @@ -295,7 +347,7 @@ export async function writeLiteralContent({ aposHost, aposExternalFrontKey, outD const literalContent = []; let files; try { - files = await readdir(CACHE_DIR); + files = await readdir(getCacheDir()); } catch { throw new Error( 'Apostrophe static build cache not found. Ensure the `[...slug].astro` page calls ' + @@ -307,7 +359,7 @@ export async function writeLiteralContent({ aposHost, aposExternalFrontKey, outD continue; } const data = JSON.parse( - await readFile(join(CACHE_DIR, file), 'utf-8') + await readFile(join(getCacheDir(), file), 'utf-8') ); for (const entry of (data.literalContent || [])) { if (!seen.has(entry.url)) { @@ -401,7 +453,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 +587,8 @@ export function writePostBuildSummary({ literal, attachments, logger }) { } export async function cleanupCache() { - await rm(CACHE_DIR, { recursive: true, force: true }).catch(() => {}); + if (!cacheDir) { + return; + } + await rm(cacheDir, { recursive: true, force: true }).catch(() => {}); } 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 b8a91963a7..60bac07773 100644 --- a/packages/apostrophe-astro/package.json +++ b/packages/apostrophe-astro/package.json @@ -10,11 +10,24 @@ }, "homepage": "https://github.com/apostrophecms/apostrophe/tree/main/packages/apostrophe-astro#readme", "main": "index.js", + "types": "types/index.d.ts", + "scripts": { + "build:types": "tsc", + "test": "mocha" + }, "author": "Apostrophe Technologies", "license": "MIT", "dependencies": { "lodash.deburr": "^4.1.0", "sluggo": "^1.0.0", "undici": "^8.5.0" + }, + "peerDependencies": { + "astro": ">=5.0.0 <8.0.0" + }, + "devDependencies": { + "esmock": "^2.7.6", + "mocha": "^11.7.6", + "typescript": "^6.0.3" } } \ No newline at end of file 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' +); 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/index.d.ts b/packages/apostrophe-astro/types/helpers/index.d.ts new file mode 100644 index 0000000000..87a645e758 --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/index.d.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000000..0187647d67 --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/server/fetch.d.ts @@ -0,0 +1,34 @@ +/** + * 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; 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..94d9c11ec3 --- /dev/null +++ b/packages/apostrophe-astro/types/helpers/server/index.d.ts @@ -0,0 +1,3 @@ +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/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..6b655945bc --- /dev/null +++ b/packages/apostrophe-astro/types/lib/aposPageFetch.d.ts @@ -0,0 +1,6 @@ +/** + * @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; 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..a46bfc618d --- /dev/null +++ b/packages/apostrophe-astro/types/lib/static.d.ts @@ -0,0 +1,188 @@ +/** + * 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. + */ +export function setStaticCacheDir(dir: string): void; +/** + * 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..7bdbdd1f46 --- /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): import("vite").Plugin; 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..aa00b9237b --- /dev/null +++ b/packages/apostrophe-astro/vite/vite-plugin-apostrophe-generated-config.js @@ -0,0 +1,226 @@ +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 +}) { + // 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. + // 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(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(dotDir, 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(dotDir, 'doctypes.js'), doctypesContent); + await writeFile(join(pkgDir, '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; + const rootImporter = join(projectRoot, 'astro.config.mjs'); + + /** + * 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, rootImporter, { 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, rootImporter)); + } + } + }; +}