├── .gitignore
├── .gitlab-ci.yml
├── components
├── layouts
│ ├── AposRefreshLayout.astro
│ ├── AposRunLayout.astro
│ ├── AposLayout.astro
│ └── AposEditLayout.astro
├── AposRenderAreaForApi.astro
├── AposTemplate.astro
├── AposWidget.astro
└── AposArea.astro
├── widgets
├── LayoutColumnWidget.astro
├── LayoutWidget.astro
└── LayoutColumn.astro
├── package.json
├── lib
├── util.js
├── aposRequest.js
├── aposPageFetch.js
├── aposSetQueryParameter.js
├── getAreaForApi.js
├── aposResponse.js
└── attachment.js
├── endpoints
├── aposProxy.js
└── renderWidget.astro
├── vite
├── vite-plugin-apostrophe-config.js
└── vite-plugin-apostrophe-doctype.js
├── index.js
├── CHANGELOG.md
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | .DS_Store
3 |
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | include:
2 | - project: 'gitlab-ci/ci-templates'
3 | ref: $CI_TEMPLATE_VERSION
4 | file: 'module-template.yml'
5 |
--------------------------------------------------------------------------------
/components/layouts/AposRefreshLayout.astro:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | ---
4 |
5 |
6 |
--------------------------------------------------------------------------------
/widgets/LayoutColumnWidget.astro:
--------------------------------------------------------------------------------
1 | ---
2 | const { widget } = Astro.props;
3 | import AposArea from "../components/AposArea.astro";
4 |
5 | // This component is used only when rendering the Admin UI.
6 | // The public facing rendering is handled by LayoutColumn.astro
7 | // via the newly introduced widgetComponent prop on AposArea.
8 | ---
9 |
10 |
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@apostrophecms/apostrophe-astro",
3 | "version": "1.7.1",
4 | "type": "module",
5 | "description": "Apostrophe integration for Astro",
6 | "main": "index.js",
7 | "author": "Apostrophe Technologies",
8 | "license": "MIT",
9 | "dependencies": {
10 | "lodash.deburr": "^4.1.0",
11 | "sluggo": "^1.0.0",
12 | "undici": "^6.21.1"
13 | }
14 | }
--------------------------------------------------------------------------------
/components/AposRenderAreaForApi.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import getAreaForApi from '../lib/getAreaForApi.js';
3 | import AposWidget from './AposWidget.astro';
4 |
5 | const items = await getAreaForApi(Astro);
6 |
7 | ---
8 | {
9 | items?.map(({ widget, options, props }) => {
10 | return (
11 |
14 | );
15 | })
16 | }
17 |
--------------------------------------------------------------------------------
/lib/util.js:
--------------------------------------------------------------------------------
1 | import sluggo from "sluggo";
2 | import deburr from "lodash.deburr";
3 |
4 | /**
5 | * Apostrophe compatible slugify helper.
6 | *
7 | * @param {string} text
8 | * @param {import('sluggo').Options} options
9 | * @param {boolean} options.stripAccents - Whether to strip accents from characters.
10 | * @returns
11 | */
12 | export function slugify(text, options) {
13 | const { stripAccents, ...opts } = options || {};
14 | const slug = sluggo(text, opts);
15 | if (stripAccents) {
16 | return deburr(slug);
17 | }
18 | return slug;
19 | }
20 |
--------------------------------------------------------------------------------
/lib/aposRequest.js:
--------------------------------------------------------------------------------
1 | export default function(req) {
2 | const request = new Request(req);
3 | const key = process.env.APOS_EXTERNAL_FRONT_KEY;
4 | if (!key) {
5 | throw new Error('APOS_EXTERNAL_FRONT_KEY environment variable must be set,\nhere and in the Apostrophe app');
6 | }
7 | request.headers.set('x-requested-with', 'AposExternalFront');
8 | request.headers.set('apos-external-front-key', key);
9 | // Prevent certain values of Connection, such as Upgrade, from causing an undici error in Node.js fetch
10 | request.headers.delete('Connection');
11 | return request;
12 | }
13 |
--------------------------------------------------------------------------------
/endpoints/aposProxy.js:
--------------------------------------------------------------------------------
1 | import aposResponse from "../lib/aposResponse";
2 |
3 | export async function ALL({ params, request, redirect }) {
4 | try {
5 | // Prevent certain values of Connection, such as Upgrade, from causing an undici error in Node.js fetch
6 | request.headers.delete('Connection');
7 | const response = await aposResponse(request);
8 | if ([301, 302, 307, 308].includes(response.status)) {
9 | return redirect(response.headers.get('location'), response.status);
10 | }
11 | return response;
12 | } catch (e) {
13 | return new Response(e.message, { status: 500 });
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/lib/aposPageFetch.js:
--------------------------------------------------------------------------------
1 | import aposResponse from './aposResponse.js';
2 | import aposRequest from './aposRequest.js';
3 |
4 | export default async function aposPageFetch(req) {
5 | let aposData = {};
6 | try {
7 | const request = aposRequest(req);
8 | const response = await aposResponse(request);
9 | aposData = await response.json();
10 | aposData.aposResponseHeaders = response.headers;
11 | if (aposData.template === '@apostrophecms/page:notFound') {
12 | aposData.notFound = true;
13 | }
14 | } catch (e) {
15 | console.error('error:', e);
16 | aposData.errorFetchingPage = e;
17 | aposData.page = {
18 | type: 'apos-fetch-error'
19 | };
20 | }
21 | return aposData;
22 | }
--------------------------------------------------------------------------------
/lib/aposSetQueryParameter.js:
--------------------------------------------------------------------------------
1 | // Add, update or remove the named query parameter
2 | // and return a new URL.
3 | //
4 | // Typically Astro.url is passed in.
5 | //
6 | // If value is undefined, null or empty it is removed
7 | // from the query string.
8 |
9 | export default function(url, name, value) {
10 | const newUrl = new URL(url);
11 | // Internal query parameters not suitable for public facing URLs
12 | newUrl.searchParams.delete('aposRefresh');
13 | newUrl.searchParams.delete('aposMode');
14 | newUrl.searchParams.delete('aposEdit');
15 | if ((value == null) || (value === '')) {
16 | newUrl.searchParams.delete(name);
17 | } else {
18 | newUrl.searchParams.set(name, value);
19 | }
20 | return newUrl;
21 | }
22 |
--------------------------------------------------------------------------------
/widgets/LayoutWidget.astro:
--------------------------------------------------------------------------------
1 | ---
2 | const { widget, options } = Astro.props;
3 | import AposArea from "../components/AposArea.astro";
4 | import LayoutColumn from "./LayoutColumn.astro";
5 | ---
6 |
7 |
29 |
--------------------------------------------------------------------------------
/components/layouts/AposRunLayout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | const { title, bodyClass, aposData } = Astro.props;
3 | ---
4 |
5 |
6 |
7 |
8 |
9 |
10 | {title}
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/endpoints/renderWidget.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import AposWidget from "../components/AposWidget.astro";
3 | import aposRequest from "../lib/aposRequest.js";
4 | import aposResponse from "../lib/aposResponse.js";
5 |
6 | const request = aposRequest(Astro.request);
7 | const response = await aposResponse(request);
8 | const text = await response.text();
9 | const statusCode = response.status;
10 |
11 | let responseBody = { widget: null };
12 | let shouldRender = true;
13 | let renderWarn = false;
14 | try {
15 | if (text === "aposLivePreviewSchemaNotYetValid") {
16 | shouldRender = false;
17 | } else {
18 | responseBody = JSON.parse(text);
19 | }
20 | if (statusCode === 400) {
21 | shouldRender = false;
22 | renderWarn = true;
23 | }
24 | } catch (error) {
25 | console.error("Error:", error);
26 | throw new Error("There was an issue while parsing a widget response.");
27 | }
28 | const { widget, ...props } = responseBody;
29 | if (!widget && shouldRender) {
30 | throw new Error(
31 | "There was an issue while rendering the widget in the renderWidget endpoint."
32 | );
33 | }
34 | ---
35 |
36 | {widget && !renderWarn && }
37 | {renderWarn && Unable to render this widget.
}
38 |
--------------------------------------------------------------------------------
/widgets/LayoutColumn.astro:
--------------------------------------------------------------------------------
1 | ---
2 | const { canEdit, widget, area } = Astro.props;
3 | import AposArea from "../components/AposArea.astro";
4 |
5 | function detectLastTabletFullWidthItem(widgets) {
6 | if (!Array.isArray(widgets)) {
7 | return;
8 | }
9 | const items = widgets.filter((widget) => widget.tablet.show);
10 | if (items.length % 2 === 0) {
11 | return;
12 | }
13 | items.sort(
14 | (a, b) =>
15 | (a.tablet.order ?? a.desktop.order) - (b.tablet.order ?? b.desktop.order)
16 | );
17 | return items[items.length - 1]._id;
18 | }
19 |
20 | const lastId = detectLastTabletFullWidthItem(area?.items);
21 |
22 | const attributes = {
23 | ...(canEdit ? { "data-apos-widget": widget._id } : {}),
24 | "data-visible-tablet": widget.tablet.show,
25 | "data-visible-mobile": widget.mobile.show,
26 | ...(lastId && widget._id === lastId && { "data-tablet-full": true }),
27 | style: {
28 | "--colstart": widget.desktop.colstart,
29 | "--colspan": widget.desktop.colspan,
30 | "--rowstart": widget.desktop.rowstart,
31 | "--rowspan": widget.desktop.rowspan,
32 | "--justify": widget.desktop.justify,
33 | "--align": widget.desktop.align,
34 | "--order": widget.desktop.order,
35 | },
36 | };
37 | ---
38 |
39 |
42 |
--------------------------------------------------------------------------------
/lib/getAreaForApi.js:
--------------------------------------------------------------------------------
1 | export default async function getDataForInlineRender(Astro) {
2 | if (!(Astro && Astro.request)) {
3 | usage();
4 | }
5 | if (Astro.request.method !== 'POST') {
6 | throw new Error('POST with JSON data expected');
7 | }
8 | if (Astro.request.headers.get('apos-external-front-key') !== process.env.APOS_EXTERNAL_FRONT_KEY) {
9 | throw new Error('apos-external-front-key header missing or incorrect');
10 | }
11 | const data = await Astro.request.json();
12 | const area = data.area;
13 | const widgetOptions = getWidgetOptions(area.options);
14 | return area.items.map(item => {
15 | const options = {
16 | ...item._options,
17 | ...widgetOptions[item.type]
18 | };
19 | const { _options, ...cleanItem } = item;
20 | return {
21 | widget: cleanItem,
22 | options,
23 | ...Astro.props
24 | };
25 | });
26 | }
27 |
28 | function usage() {
29 | throw new Error('Pass { Astro, AstroContainer, AposWidget } to this function');
30 | }
31 |
32 | function getWidgetOptions(options) {
33 | let widgets = options.widgets || {};
34 |
35 | if (options.groups) {
36 | for (const group of Object.keys(options.groups)) {
37 | widgets = {
38 | ...widgets,
39 | ...options.groups[group].widgets
40 | };
41 | }
42 | }
43 | return widgets;
44 | }
45 |
--------------------------------------------------------------------------------
/vite/vite-plugin-apostrophe-config.js:
--------------------------------------------------------------------------------
1 | export function vitePluginApostropheConfig(
2 | aposHost,
3 | forwardHeaders = null,
4 | viewTransitionWorkaround,
5 | includeResponseHeaders = null,
6 | excludeRequestHeaders = null
7 | ) {
8 | const virtualModuleId = "virtual:apostrophe-config";
9 | const resolvedVirtualModuleId = "\0" + virtualModuleId;
10 |
11 | // Use includeResponseHeaders if provided, fallback to forwardHeaders for BC
12 | const headersToInclude = includeResponseHeaders || forwardHeaders;
13 |
14 | return {
15 | name: "vite-plugin-apostrophe-config",
16 | async resolveId(id) {
17 | if (id === virtualModuleId) {
18 | return resolvedVirtualModuleId;
19 | }
20 | },
21 | async load(id) {
22 | if (id === resolvedVirtualModuleId) {
23 | return `
24 | export default {
25 | aposHost: ${JSON.stringify(aposHost)}
26 | ${headersToInclude ? `,
27 | includeResponseHeaders: ${JSON.stringify(headersToInclude)}` : ''
28 | }
29 | ${excludeRequestHeaders ? `,
30 | excludeRequestHeaders: ${JSON.stringify(excludeRequestHeaders)}` : ''
31 | }
32 | ${viewTransitionWorkaround ? `,
33 | viewTransitionWorkaround: true` : ''
34 | }
35 | }`
36 | ;
37 | }
38 | },
39 | };
40 | };
41 |
--------------------------------------------------------------------------------
/vite/vite-plugin-apostrophe-doctype.js:
--------------------------------------------------------------------------------
1 | export function vitePluginApostropheDoctype(widgetsMapping, templatesMapping) {
2 |
3 | const virtualModuleId = "virtual:apostrophe-doctypes";
4 | const resolvedVirtualModuleId = "\0" + virtualModuleId;
5 |
6 | return {
7 | name: "vite-plugin-apostrophe-doctypes",
8 | async resolveId(id) {
9 | if (id === virtualModuleId) {
10 | return resolvedVirtualModuleId;
11 | }
12 | },
13 | async load(id) {
14 | if (id === resolvedVirtualModuleId) {
15 | /**
16 | * Handle registered doctypes
17 | */
18 | const resolvedWidgetsId = await this.resolve(widgetsMapping);
19 | const resolvedTemplatesId = await this.resolve(templatesMapping);
20 | /**
21 | * if the component cannot be resolved
22 | */
23 | if (!resolvedWidgetsId || !resolvedTemplatesId) {
24 | throw new Error(
25 | `Widget or Templates mapping is missing.`
26 | );
27 | } else {
28 | /**
29 | * if the component can be resolved, add it to the imports array
30 | */
31 | return `import { default as widgets } from "${resolvedWidgetsId.id}";
32 | import { default as templates } from "${resolvedTemplatesId.id}";
33 | export { widgets, templates }`
34 | }
35 | }
36 | },
37 | };
38 | }
--------------------------------------------------------------------------------
/components/AposTemplate.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { templates } from 'virtual:apostrophe-doctypes';
3 |
4 | const { aposData } = Astro.props;
5 |
6 | // Handle connection and authentication errors
7 | if (aposData.errorFetchingPage || aposData.page?.type === 'apos-fetch-error') {
8 | // Connection refused error
9 | if (aposData.errorFetchingPage?.code === 'ECONNREFUSED') {
10 | throw new Error(
11 | 'Unable to connect to Apostrophe CMS server. ' +
12 | 'Please ensure the Apostrophe server is running and try again.'
13 | );
14 | }
15 |
16 | // Header key mismatch error
17 | if (aposData.errorFetchingPage instanceof SyntaxError &&
18 | aposData.errorFetchingPage.message.includes('forbidden')) {
19 | throw new Error(
20 | 'Authentication failed: Header keys do not match between frontend and backend. ' +
21 | 'Please check your APOS_EXTERNAL_FRONT_KEY environment variables match in both projects.'
22 | );
23 | }
24 |
25 | // Generic fetch error
26 | throw new Error(
27 | 'Error fetching page from Apostrophe CMS: ' +
28 | (aposData.errorFetchingPage?.message || 'Unknown error')
29 | );
30 | }
31 |
32 |
33 | const Template = templates[aposData.template]
34 | || templates[aposData.template?.replace(':page', '')]
35 | || templates[aposData.module]
36 | || templates['apos-no-template'];
37 |
38 | ---
39 | { Template && }
40 |
--------------------------------------------------------------------------------
/components/AposWidget.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { widgets } from 'virtual:apostrophe-doctypes'
3 |
4 | const { widget, options, ...props } = Astro.props;
5 | const isEdit = widget._edit && Astro.url.searchParams.get('aposEdit');
6 |
7 | const key = widget.type;
8 | const componentFound = key in widgets;
9 |
10 | let Component;
11 |
12 | if (!componentFound) {
13 | console.error(`Missing widget mapping for ${key}`);
14 | } else {
15 | Component = widgets[key];
16 | }
17 |
18 | // Handle anchor functionality using backend options
19 | const hasAnchor = widget?.anchorId;
20 | const anchorsDisabled = options?.anchors === false;
21 | const shouldWrapWithAnchor = hasAnchor && !anchorsDisabled;
22 |
23 | // Get anchor attribute from backend options (defaults to 'id')
24 | const anchorAttribute = options?.anchorAttribute || 'id';
25 |
26 | // Determine wrapper element and attributes
27 | let WrapperElement = 'div';
28 | let wrapperAttributes = {};
29 |
30 | if (isEdit) {
31 | WrapperElement = 'div';
32 | wrapperAttributes['data-apos-widget'] = widget._id;
33 | // Also add anchor if present to maintain consistent layout
34 | if (shouldWrapWithAnchor) {
35 | wrapperAttributes[anchorAttribute] = widget.anchorId;
36 | }
37 | } else if (shouldWrapWithAnchor) {
38 | WrapperElement = 'div';
39 | wrapperAttributes[anchorAttribute] = widget.anchorId;
40 | }
41 |
42 | const shouldWrap = isEdit || shouldWrapWithAnchor;
43 | ---
44 |
45 | { Component &&
46 | <>
47 | {shouldWrap ? (
48 |
49 |
50 |
51 | ) : (
52 |
53 | )}
54 | >
55 | }
--------------------------------------------------------------------------------
/components/layouts/AposLayout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import AposRunLayout from "./AposRunLayout.astro";
3 | import AposEditLayout from "./AposEditLayout.astro";
4 | import AposRefreshLayout from "./AposRefreshLayout.astro";
5 | import config from 'virtual:apostrophe-config';
6 |
7 | const { aposData } = Astro.props;
8 |
9 | // Forward Apostrophe response headers to Astro based on the config
10 | let headersToInclude = config.includeResponseHeaders;
11 | if (!headersToInclude && config.forwardHeaders) {
12 | console.warn('forwardHeaders is deprecated. Please use includeResponseHeaders instead.');
13 | headersToInclude = config.forwardHeaders;
14 | }
15 |
16 | if (headersToInclude && Array.isArray(headersToInclude)) {
17 | const headers = aposData.aposResponseHeaders;
18 | if (headers) {
19 | for (const header of headersToInclude) {
20 | let aposHeader = headers.get(header);
21 | // Astro is not compatible with nonce in CSP
22 | if (aposHeader && header === 'content-security-policy') {
23 | aposHeader = aposHeader.replace(/script-src[^;]+/g, (match) => match.replace(/'nonce-[^']+'\s*/g, ''));
24 | }
25 | if (aposHeader) {
26 | Astro.response.headers.set(header, aposHeader);
27 | }
28 | }
29 | }
30 | }
31 |
32 | // Manage the page not found and error fetching page cases
33 |
34 | if (aposData.errorFetchingPage) {
35 | Astro.response.status = 500;
36 | }
37 |
38 | const LayoutComponent = aposData.user
39 | ? (Astro.url.searchParams.get('aposRefresh')
40 | ? AposRefreshLayout
41 | : AposEditLayout)
42 | : AposRunLayout;
43 | ---
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/components/layouts/AposEditLayout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import config from "virtual:apostrophe-config";
3 |
4 | const { title, bodyClass, aposData } = Astro.props;
5 | const { viewTransitionWorkaround } = config;
6 | const cacheBuster = viewTransitionWorkaround
7 | ? `?cb=${new Date().getTime()}`
8 | : "";
9 |
10 | let bundlesCss = "";
11 | let bundlesJs = "";
12 |
13 | if (aposData.bundleMarkup) {
14 | bundlesCss =
15 | aposData.bundleMarkup.css
16 | ?.map((bundle) => bundle.replace(/\.css$/, ".css" + cacheBuster))
17 | .join("") ?? "";
18 |
19 | bundlesJs =
20 | aposData.bundleMarkup.js
21 | ?.map((bundle) => bundle.replace(/\.js$/, ".js" + cacheBuster))
22 | .join("") ?? "";
23 | } else {
24 | bundlesJs = ``;
25 | }
26 | ---
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | {title}
35 |
36 |
37 |
38 |
39 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/components/AposArea.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import AposWidget from "./AposWidget.astro";
3 |
4 | const {
5 | area,
6 | aposAttributes,
7 | aposStyle,
8 | aposClassName,
9 | aposParentOptions,
10 | widgetComponent,
11 | ...props
12 | } = Astro.props;
13 |
14 | let attributes = {};
15 |
16 | const widgets = area?.items || [];
17 |
18 | const isEdit = area?._edit && Astro.url.searchParams.get("aposEdit");
19 | const forceWrapper = aposAttributes || aposStyle || aposClassName;
20 |
21 | const WidgetComponent = widgetComponent ?? AposWidget;
22 |
23 | if (isEdit) {
24 | attributes = {
25 | "data-apos-area-newly-editable": "",
26 | "data-doc-id": area?._docId,
27 | "data-area-id": area?._id,
28 | "data-field-id": area?.field?._id,
29 | "data-module": area?.field?.moduleName,
30 | "data-options": JSON.stringify(area?.options),
31 | "data-choices": JSON.stringify(area?.choices),
32 | "data-parent-options": JSON.stringify(aposParentOptions ?? {}),
33 | style: aposStyle ?? "",
34 | className: aposClassName ?? "",
35 | ...(aposAttributes ?? {}),
36 | data: JSON.stringify(area),
37 | };
38 | } else if (forceWrapper) {
39 | attributes = {
40 | style: aposStyle ?? "",
41 | className: aposClassName ?? "",
42 | ...(aposAttributes ?? {}),
43 | };
44 | }
45 | const Wrapper = isEdit || forceWrapper ? "div" : Fragment;
46 | const widgetOptions = getWidgetOptions(area.options);
47 |
48 | function getWidgetOptions(options) {
49 | let widgets = options.widgets || {};
50 |
51 | if (options.groups) {
52 | for (const group of Object.keys(options.groups)) {
53 | widgets = {
54 | ...widgets,
55 | ...options.groups[group].widgets,
56 | };
57 | }
58 | }
59 | return widgets;
60 | }
61 | ---
62 |
63 |
64 | {
65 | widgets?.map((item) => {
66 | const options = {
67 | ...item._options,
68 | ...widgetOptions[item.type],
69 | };
70 | const { _options, ...cleanItem } = item;
71 | return (
72 |
79 | );
80 | })
81 | }
82 |
83 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import { vitePluginApostropheDoctype } from './vite/vite-plugin-apostrophe-doctype.js';
2 | import { vitePluginApostropheConfig } from './vite/vite-plugin-apostrophe-config.js';
3 |
4 | export default function apostropheIntegration(options) {
5 | return {
6 | name: 'apostrophe-integration',
7 | hooks: {
8 | "astro:config:setup": ({ injectRoute, updateConfig, injectScript }) => {
9 | if (!options.widgetsMapping || !options.templatesMapping) {
10 | throw new Error('Missing required options')
11 | }
12 | updateConfig({
13 | vite: {
14 | plugins: [
15 | vitePluginApostropheDoctype(
16 | options.widgetsMapping,
17 | options.templatesMapping
18 | ),
19 | vitePluginApostropheConfig(
20 | options.aposHost,
21 | options.forwardHeaders,
22 | options.viewTransitionWorkaround,
23 | options.includeResponseHeaders,
24 | options.excludeRequestHeaders
25 | ),
26 | ],
27 | },
28 | });
29 | const inject = [
30 | '/apos-frontend/[...slug]',
31 | '/api/v1/[...slug]',
32 | '/[locale]/api/v1/[...slug]',
33 | '/login',
34 | '/[locale]/login',
35 | '/uploads/[...slug]',
36 | ...(options.proxyRoutes || [])
37 | ];
38 | for (const pattern of inject) {
39 | // duplication of entrypoint needed for Astro 3.x support per
40 | // https://docs.astro.build/en/guides/upgrade-to/v4/#renamed-entrypoint-integrations-api
41 | injectRoute({
42 | pattern,
43 | entryPoint: '@apostrophecms/apostrophe-astro/endpoints/aposProxy.js',
44 | entrypoint: '@apostrophecms/apostrophe-astro/endpoints/aposProxy.js'
45 | });
46 | }
47 | // Different pattern from the rest
48 | injectRoute({
49 | pattern: '/[locale]/api/v1/@apostrophecms/area/render-widget',
50 | entryPoint: '@apostrophecms/apostrophe-astro/endpoints/renderWidget.astro',
51 | entrypoint: '@apostrophecms/apostrophe-astro/endpoints/renderWidget.astro'
52 | });
53 | injectRoute({
54 | pattern: '/api/v1/@apostrophecms/area/render-widget',
55 | entryPoint: '@apostrophecms/apostrophe-astro/endpoints/renderWidget.astro',
56 | entrypoint: '@apostrophecms/apostrophe-astro/endpoints/renderWidget.astro'
57 | });
58 | }
59 | }
60 | };
61 | };
62 |
63 |
--------------------------------------------------------------------------------
/lib/aposResponse.js:
--------------------------------------------------------------------------------
1 | import config from 'virtual:apostrophe-config';
2 | import { request } from 'undici';
3 | import zlib from 'zlib';
4 | import { promisify } from 'util';
5 |
6 | // Promisify zlib functions
7 | const gunzipAsync = promisify(zlib.gunzip);
8 | const inflateAsync = promisify(zlib.inflate);
9 | const brotliDecompressAsync = promisify(zlib.brotliDecompress);
10 |
11 | const excludedHeadersLower = new Set(config.excludeRequestHeaders?.map(h => h.toLowerCase()) || []);
12 |
13 | function looksLikeChunkedEncoding(buffer) {
14 | const str = buffer.toString('utf8');
15 |
16 | // chunked responses start with a hex line followed by \r\n
17 | // and end with "0\r\n\r\n"
18 | const startsWithHex = /^[0-9a-fA-F]+\r\n/.test(str);
19 | const endsWithTerminator = /0\r\n\r\n$/.test(str);
20 |
21 | return startsWithHex && endsWithTerminator;
22 | }
23 |
24 | export default async function aposResponse(req) {
25 | try {
26 | // Host header should not contain the protocol or a path
27 | if (req.headers.get('host').includes('/')) {
28 | return new Response('Invalid Host header', {
29 | status: 400,
30 | statusText: 'Bad Request'
31 | });
32 | }
33 |
34 | // Prepare URL for the backend request
35 | const url = new URL(req.url);
36 |
37 | const aposHost = process.env.APOS_HOST || config.aposHost;
38 | const aposUrl = new URL(url.pathname, aposHost);
39 | aposUrl.search = url.search;
40 |
41 | // Prepare headers, excluding any specified in config
42 | const requestHeaders = {};
43 | for (const [name, value] of req.headers) {
44 | if (!excludedHeadersLower.has(name.toLowerCase())) {
45 | requestHeaders[name] = value;
46 | }
47 | }
48 |
49 | // Make the request to the backend
50 | const res = await request(aposUrl.href, {
51 | headers: requestHeaders,
52 | method: req.method,
53 | body: req.body
54 | });
55 |
56 | // Prepare response headers
57 | const responseHeaders = new Headers();
58 | Object.entries(res.headers).forEach(([key, value]) => {
59 | if (Array.isArray(value)) {
60 | value.forEach(v => responseHeaders.append(key, v));
61 | } else {
62 | responseHeaders.set(key, value);
63 | }
64 | });
65 |
66 | const { headers, statusCode, ...rest } = res;
67 |
68 | // Handle empty responses (status codes that should not have bodies)
69 | if ([204, 304].includes(statusCode)) {
70 | return new Response(null, { ...rest, status: statusCode, headers: responseHeaders });
71 | }
72 |
73 | // Check for content-encoding header
74 | const contentEncoding = res.headers['content-encoding'] || '';
75 |
76 | // If no compression or HEAD/CONNECT request, return as-is
77 | if (!contentEncoding || req.method === 'HEAD' || req.method === 'CONNECT') {
78 | return new Response(res.body, { ...rest, status: statusCode, headers: responseHeaders });
79 | }
80 |
81 | // Parse and process content encodings
82 | const codings = contentEncoding.toLowerCase().split(',').map(x => x.trim());
83 |
84 | try {
85 | // Get the body as an ArrayBuffer
86 | const bodyArrayBuffer = await res.body.arrayBuffer();
87 |
88 | // Convert to Buffer for Node.js zlib functions
89 | let buffer = Buffer.from(bodyArrayBuffer);
90 |
91 | // Apply decoders in order
92 | for (const coding of codings) {
93 | try {
94 | if (coding === 'gzip' || coding === 'x-gzip') {
95 | buffer = await gunzipAsync(buffer);
96 | } else if (coding === 'deflate') {
97 | buffer = await inflateAsync(buffer);
98 | } else if (coding === 'br') {
99 | buffer = await brotliDecompressAsync(buffer);
100 | }
101 | // Skip unknown encodings silently
102 | } catch (decompressError) {
103 | // If decompression fails, return original response
104 | return new Response(new Uint8Array(bodyArrayBuffer), {
105 | ...rest,
106 | status: statusCode,
107 | headers: responseHeaders
108 | });
109 | }
110 | }
111 |
112 | if (looksLikeChunkedEncoding(buffer)) {
113 | console.warn('⚠️ Warning: response appears to be chunked-encoded. undici may not have decoded it.');
114 | }
115 |
116 | // Create response with decoded data and remove content-encoding header
117 | responseHeaders.delete('content-encoding');
118 |
119 | return new Response(buffer, {
120 | ...rest,
121 | status: statusCode,
122 | headers: responseHeaders
123 | });
124 | } catch (bodyError) {
125 | // If we can't process the body, fall back to the original response
126 | return new Response(res.body, { ...rest, status: statusCode, headers: responseHeaders });
127 | }
128 | } catch (error) {
129 | // Handle any unexpected errors
130 | return new Response(`Server error: ${error.message}`, { status: 500 });
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 1.7.1 (2025-11-25)
4 |
5 | * Add support for `sluggo` options in the `slugify` util helper and `stripAccents` option to remove accents from characters.
6 |
7 | ## 1.7.0 (2025-10-30)
8 |
9 | ### Adds
10 |
11 | * Refactor `AposArea` to accept custom style, class, and attributes, but also a `widgetComponent` prop. The latter is allowing for custom widget rendering components to be used. This is particularly useful for complex widgets like the Layout widget that need to render the output in particular order/way.
12 | * Introduce `LayoutWidget.astro` and `LayoutColumnWidget.astro` components to be used in the project level widget mapping.
13 | * Adds new `AposRenderAreaForAstro` component, specifically designed to be used to implement the new `/api/apos-external-frontend/render-area` route. This allows section template library previews to work in Astro projects and also enables calling the ApostropheCMS REST APIs with `?render-area` when using Astro on the front end. See the documentation for more information on adding this route to existing projects.
14 |
15 | ## 1.6.0 (2025-10-01)
16 |
17 | ### Adds
18 |
19 | * Adds additional handling for the `@apostrophecms/anchors` module to the `AposWidget.astro` component
20 | * Adds handling for project-level widget options, as well as actually passing per-area options to widgets. Can be accessed through the `options` prop in the widget components.
21 | * Add support for `prependMain` and `appendMain` in the `aposData` object, allowing for custom HTML to be injected into the `` sections of the layout.
22 |
23 | ### Fixes
24 |
25 | * Verify that `Host` header is correctly formatted.
26 |
27 | ## 1.5.2 (2025-09-09)
28 |
29 | ### Fixes
30 |
31 | * Corrected the link to the astro apollo pro starter kit in the documentation.
32 |
33 | ## 1.5.1 (2025-09-08)
34 |
35 | ### Changes
36 |
37 | * Guide developers to our actively supported starter kits.
38 |
39 | ## 1.5.0 (2025-08-06)
40 |
41 | ### Adds
42 |
43 | * Add support for `prependHead`, `appendHead`, `prependBody`, and `appendBody` in the `aposData` object, allowing for custom HTML to be injected into the `` and `` sections of the layout.
44 |
45 | ### Fixes
46 |
47 | * Handle deep schema validation errors when rendering widgets, so that a message is displayed in the widget preview instead of a crash.
48 |
49 | ## 1.4.0 (2025-06-11)
50 |
51 | ### Adds
52 |
53 | * Add `util` (currently containing only `slugify`) and `attachment` helpers to mimic Apostrophe's Nunjucks helpers.
54 |
55 | ### Fixes
56 |
57 | * Fix duplicate function that breaks project builds.
58 |
59 | ## 1.3.1 (2025-05-23)
60 |
61 | ### Fixes
62 |
63 | * Fix a bug when the Astro app can crash due to live widget preview in some cases.
64 |
65 | ## 1.3.0 (2025-04-16)
66 |
67 | ### Adds
68 |
69 | * Add support for decoding the response body needed for some hosting added to the `aposResponse.js`.
70 |
71 | ## 1.2.1 (2025-03-19)
72 |
73 | ### Fixes
74 |
75 | * The `lang` attribute of the `` tag now respects localization.
76 |
77 | ## 1.2.0 (2025-01-27)
78 |
79 | ### Changes
80 |
81 | * The configuration array for headers passed from Apostrophe to the browser has been changed from `forwardHeaders` to `includeResponseHeaders` with BC maintained.
82 | * A new configuration option `excludeRequestHeaders` has been added to allow exclusion of headers like `host` being sent from the browser to Apostrophe.
83 | * The `README.MD` has been updated with the new configuration options.
84 | * Clearer error messages have been added to indicate that either the backend server has not been started, or that the `APOS_EXTERNAL_FRONT_KEY` strings don't match.
85 |
86 | ## 1.1.0 (2024-11-20)
87 |
88 | ### Adds
89 |
90 | * Add support for automated bundles injection in Edit (apos) mode, required for supporting the new core "manifest" builds (when available).
91 |
92 | ## 1.0.9 (2024-10-23)
93 |
94 | * Prevent the `Connection: Upgrade` header from breaking Astro's `fetch` calls to Apostrophe when hosted in an environment that permits websocket upgrades of existing connections.
95 | * Refactored redundant code for building requests with the external front key, etc.
96 |
97 | ## 1.0.8 (2024-07-02)
98 |
99 | * The `renderWidget` route will no longer crash if there was an issue getting the `render-widget` route from Apostrophe (like a mandatory field missing), it will respond with a 500 only, with a log message in the console. Thanks to Michelin for contributing this fix.
100 |
101 | ## 1.0.7 (2024-03-28)
102 |
103 | * Visiting the `/login` page when already logged in no longer results in
104 | an undesired direct response from Apostrophe. Redirects within api routes like the login issued
105 | on the Apostrophe side are now sending a redirect response as per Astro endpoints documentation.
106 | * Page refreshes no longer alternate between displaying the admin UI and not displaying it
107 | with each refresh in certain configurations.
108 | * Thanks to Michelin for collaborating on the solution.
109 |
110 | ## 1.0.6 (2024-03-26)
111 |
112 | * Change the way we fetch from Apostrophe by using `undici` `request` method, so as all headers are correctly forwarded. As on latest Node.js versions, headers like `Host` are no more forwarded by the regular `fetch` global method.
113 |
114 | ## 1.0.5 (2024-02-07)
115 |
116 | * Compatible with Astro's `ViewTransition` feature when editing, via
117 | a workaround. Since this workaround imposes a performance penalty
118 | (only for editors, not the public), the `viewTransitionWorkaround`
119 | option must be set to `true` to enable it.
120 |
121 | ## 1.0.4 (2024-01-22)
122 |
123 | * Documentation fixes only.
124 |
125 | ## 1.0.3 (2023-12-27)
126 |
127 | * Documentation typo that impacts Linux and other case sensitive systems fixed.
128 |
129 | ## 1.0.2 (2023-12-22)
130 |
131 | * Fix bug causing pages to crash after refresh if widget
132 | grouping is used.
133 |
134 | ## 1.0.1 (2023-12-21)
135 |
136 | * Fix bug impacting two-column widgets.
137 |
138 | ## 1.0.0 (2023-12-21)
139 |
140 | * Initial release.
141 |
--------------------------------------------------------------------------------
/lib/attachment.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Utility functions for handling attachments and image related data.
3 | */
4 |
5 | const MISSING_ATTACHMENT_URL = '/images/missing-icon.svg';
6 |
7 | /**
8 | * Get the actual attachment object from either a full image object or direct attachment
9 | * @param {Object} attachmentObject - Either a full image object or direct attachment
10 | * @returns {Object|null} The attachment object
11 | */
12 | function getAttachment(attachmentObject) {
13 | if (!attachmentObject) return null;
14 |
15 | // If it's a full image object (has _fields), get its attachment
16 | if (attachmentObject._fields) {
17 | return attachmentObject.attachment;
18 | }
19 |
20 | // If it's already an attachment or has nested attachment
21 | return attachmentObject.attachment || attachmentObject;
22 | }
23 |
24 | /**
25 | * Check if attachment has multiple size variants
26 | * @param {Object} attachmentObject - Either a full image object or direct attachment
27 | * @returns {boolean} True if the attachment has multiple sizes
28 | */
29 | function isSized(attachmentObject) {
30 | const attachment = getAttachment(attachmentObject);
31 | if (!attachment) return false;
32 |
33 | if (attachment._urls && typeof attachment._urls === 'object') {
34 | return Object.keys(attachment._urls).length > 1;
35 | }
36 |
37 | return false;
38 | }
39 |
40 | /**
41 | * Get focal point coordinates from attachment or image, or return default value if invalid
42 | * @param {Object} attachmentObject - Either a full image object or direct attachment
43 | * @param {string} [defaultValue='center center'] - Default value to return if no valid focal point
44 | * @returns {string} String with focal point for styling (e.g., "50% 50%") or default value if invalid
45 | */
46 | function getFocalPoint(attachmentObject, defaultValue = 'center center') {
47 | if (!attachmentObject) return defaultValue;
48 |
49 | // Check _fields if it's from a relationship
50 | if (attachmentObject._fields &&
51 | typeof attachmentObject._fields.x === 'number' &&
52 | attachmentObject._fields.x !== null &&
53 | typeof attachmentObject._fields.y === 'number' &&
54 | attachmentObject._fields.y !== null) {
55 | return `${attachmentObject._fields.x}% ${attachmentObject._fields.y}%`;
56 | }
57 |
58 | // Check attachment object directly if it's a direct attachment
59 | const attachment = getAttachment(attachmentObject);
60 | if (attachment &&
61 | typeof attachment.x === 'number' &&
62 | attachment.x !== null &&
63 | typeof attachment.y === 'number' &&
64 | attachment.y !== null) {
65 | return `${attachment.x}% ${attachment.y}%`;
66 | }
67 |
68 | return defaultValue;
69 | }
70 |
71 | /**
72 | * Get the width from the image object, using crop dimensions if available,
73 | * otherwise falling back to original image dimensions
74 | * @param {object} imageObject - Image object from ApostropheCMS
75 | * @returns {number|undefined} The width of the image
76 | */
77 | function getWidth(imageObject) {
78 | // Use cropped width from _fields if available
79 | if (imageObject?._fields?.width !== undefined && imageObject._fields.width !== null) {
80 | return imageObject._fields.width;
81 | }
82 | // Fall back to original image width
83 | return imageObject?.attachment?.width;
84 | }
85 |
86 | /**
87 | * Get the height from the image object, using crop dimensions if available,
88 | * otherwise falling back to original image dimensions
89 | * @param {object} imageObject - Image object from ApostropheCMS
90 | * @returns {number|undefined} The height of the image
91 | */
92 | function getHeight(imageObject) {
93 | // Use cropped height from _fields if available
94 | if (imageObject?._fields?.height !== undefined && imageObject._fields.height !== null) {
95 | return imageObject._fields.height;
96 | }
97 | // Fall back to original image height
98 | return imageObject?.attachment?.height;
99 | }
100 |
101 | /**
102 | * Get the crop parameters from the image object's _fields
103 | * @param {Object} imageObject - The full image object from ApostropheCMS
104 | * @returns {Object|null} The crop parameters or null if no crop exists
105 | */
106 | function getCrop(imageObject) {
107 | // Check for crop parameters in _fields
108 | if (imageObject?._fields &&
109 | typeof imageObject._fields.left === 'number' &&
110 | typeof imageObject._fields.top === 'number' &&
111 | typeof imageObject._fields.width === 'number' &&
112 | typeof imageObject._fields.height === 'number') {
113 | return {
114 | left: imageObject._fields.left,
115 | top: imageObject._fields.top,
116 | width: imageObject._fields.width,
117 | height: imageObject._fields.height
118 | };
119 | }
120 |
121 | return null;
122 | }
123 |
124 | /**
125 | * Build the URL for an attachment with crop parameters and size
126 | * @param {string} baseUrl - The base URL for the attachment
127 | * @param {Object} crop - The crop parameters object
128 | * @param {string} [size] - The size variant name
129 | * @param {string} extension - The file extension
130 | * @returns {string} The complete URL with crop parameters
131 | */
132 | function buildAttachmentUrl(baseUrl, crop, size, extension) {
133 | let url = baseUrl;
134 |
135 | // Add crop parameters if they exist
136 | if (crop) {
137 | url += `.${crop.left}.${crop.top}.${crop.width}.${crop.height}`;
138 | }
139 |
140 | // Add size if specified
141 | if (size && size !== 'original') {
142 | url += `.${size}`;
143 | }
144 |
145 | // Add extension
146 | url += `.${extension}`;
147 |
148 | return url;
149 | }
150 |
151 | /**
152 | * Get URL for an attachment with optional size
153 | * @param {Object} imageObject - The full image object from ApostropheCMS
154 | * @param {Object} [options={}] - Options object
155 | * @param {string} [options.size] - Size variant ('one-sixth', 'one-third',
156 | * 'one-half', 'two-thirds', 'full', 'max', 'original')
157 | * @param {string} [options.missingIcon] - Custom URL for missing attachment (optional)
158 | * @returns {string} The URL for the attachment
159 | */
160 | function getAttachmentUrl(imageObject, options = {}) {
161 | const attachment = getAttachment(imageObject);
162 |
163 | if (!attachment) {
164 | console.warn('Template warning: Missing attachment, using fallback icon');
165 | return options.missingIcon || MISSING_ATTACHMENT_URL;
166 | }
167 |
168 | // Get the requested size or default to 'full'
169 | const size = options.size || 'two-thirds';
170 |
171 | // Check if we're in the just-edited state (has uncropped URLs)
172 | if (attachment._urls?.uncropped) {
173 | // During the just-edited state, the main _urls already contain the crop parameters
174 | return attachment._urls[size] || attachment._urls.original;
175 | }
176 |
177 | // Get crop parameters from the image object's _fields
178 | const crop = getCrop(imageObject);
179 |
180 | // If we have _urls and no crop, use the pre-generated URL
181 | if (attachment._urls && !crop) {
182 | return attachment._urls[size] || attachment._urls.original;
183 | }
184 |
185 | // Derive the base URL path from _urls if available
186 | let baseUrl;
187 | if (attachment._urls?.original) {
188 | // Remove the extension from the original URL to get the base path
189 | baseUrl = attachment._urls.original.replace(`.${attachment.extension}`, '');
190 | }
191 |
192 | // Build the complete URL with crop parameters and size
193 | return buildAttachmentUrl(baseUrl, crop, size, attachment.extension);
194 | }
195 |
196 | /**
197 | * Generate a srcset for an image attachment
198 | * @param {Object} attachmentObject - Either a full image object or direct attachment
199 | * @param {Object} [options] - Options for generating the srcset
200 | * @param {Array} [options.sizes] - Array of custom size objects to override the default sizes
201 | * @param {string} options.sizes[].name - The name of the size (e.g., 'small', 'medium')
202 | * @param {number} options.sizes[].width - The width of the image for this size
203 | * @param {number} [options.sizes[].height] - The height of the image for this size (optional)
204 | * @returns {string} The srcset string
205 | */
206 | function getAttachmentSrcset(attachmentObject, options = {}) {
207 | if (!attachmentObject || !isSized(attachmentObject)) {
208 | return '';
209 | }
210 |
211 | const defaultSizes = [
212 | { name: 'one-sixth', width: 190, height: 350 },
213 | { name: 'one-third', width: 380, height: 700 },
214 | { name: 'one-half', width: 570, height: 700 },
215 | { name: 'two-thirds', width: 760, height: 760 },
216 | { name: 'full', width: 1140, height: 1140 },
217 | { name: 'max', width: 1600, height: 1600}
218 | ];
219 |
220 | const sizes = options.sizes || defaultSizes;
221 |
222 | return sizes
223 | .map(size => `${getAttachmentUrl(attachmentObject, { ...options, size: size.name })} ${size.width}w`)
224 | .join(', ');
225 | }
226 |
227 | export {
228 | getFocalPoint,
229 | getWidth,
230 | getHeight,
231 | getAttachmentUrl,
232 | getAttachmentSrcset
233 | };
234 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
@apostrophecms/apostrophe-astro
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | # Astro integration for ApostropheCMS
16 |
17 | This module integrates ApostropheCMS into your [Astro](https://astro.build/) application.
18 |
19 | ## About Astro
20 |
21 | Astro provides a "universal bridge" to run modern frontend frameworks like React, Vue,
22 | and SvelteJS on the server side, as well as a straightforward, JSX-like template
23 | language of its own to meld everything together.
24 |
25 | ## Bringing ApostropheCMS and Astro together
26 |
27 | The intent of this integration is to let Apostrophe manage content, handle routing of URLs and fetch content,
28 | and let Astro take the responsibility for the rendering of pages
29 | and any associated logic using your framework(s) of choice like React, Vue.js,
30 | Svelte, etc. (see the [Astro integrations page](https://docs.astro.build/en/guides/integrations-guide/) for more).
31 |
32 | **This module also brings the ApostropheCMS Admin UI in your Astro application**, so you can manage your site exactly as if you were in a "normal" Apostrophe instance.
33 |
34 | When you use this module, you will have **two** projects:
35 |
36 | 1. An Astro project. This is where you write your templates and frontend code.
37 |
38 | 2. An Apostrophe project. This is where you define your page types, widget types
39 | and other content types with their schemas and other customizations.
40 |
41 | This kind of dual-project CMS integration is typical for Astro.
42 |
43 | The best way to keep everything consistent is to build these in `frontend` and `backend` subdirectories of the same git repository.
44 |
45 | To get you started quickly, we recommend one of our official Astro starter kits:
46 |
47 | * [apostrophecms/starter-kit-astro-essentials](https://github.com/apostrophecms/starter-kit-astro-essentials) is best for a clean start with as little extra code as possible.
48 | * [apostrophecms/starter-kit-astro-apollo](https://github.com/apostrophecms/starter-kit-astro-apollo) is a full-fledged project with a blog, a design system and other nice touches.
49 | * [apostrophecms/starter-kit-astro-apollo-pro](https://github.com/apostrophecms/starter-kit-astro-apollo-pro) is great for those who expect to use our [Pro features](https://apostrophecms.com/pro) right away, but keep in mind you can add those modules to any project later.
50 |
51 | > 💡 These combined Astro + Apostrophe projects are best launched by forking the repository, not using our CLI. Follow the links to see how to fork these projects and get started on your own.
52 |
53 | You can also adapt your own existing ApostropheCMS project as explained below.
54 |
55 | > Note that this module, `@apostrophecms/apostrophe-astro`, is meant to be installed as a dependency of your *Astro project*,
56 | > not your Apostrophe project.
57 |
58 | This module is currently designed for use with Astro's `output: 'server'` setting (SSR mode), so that you can edit your content
59 | directly on the page. Support for export as a static site is under consideration for the future.
60 |
61 | ## Installation
62 |
63 | If you did not fork the sample projects above, you will need to install this
64 | module into your Astro project. Install this module in your
65 | **Astro project**, not your ApostropheCMS project:
66 |
67 | ```shell
68 | cd my-astro-project
69 | npm install @apostrophecms/apostrophe-astro
70 | ```
71 |
72 | *Astro 3.x and 4.x are both supported.*
73 |
74 | ## Security
75 |
76 | You **must** set the `APOS_EXTERNAL_FRONT_KEY` environment variable to a secret
77 | value when running your Astro project, and also set the same variable to the same value when running your Apostrophe application.
78 | This ensures that other sites on the web cannot fetch excessive amounts of
79 | information from ApostropheCMS without your permission.
80 |
81 | ## Configuration (Astro)
82 |
83 | Since this is an Astro integration, you will need to add it to your Astro project's `astro.config.mjs` file.
84 | Here is a working `astro.config.mjs` file for a project with an Apostrophe CMS backend.
85 |
86 | ```js
87 | import { defineConfig } from 'astro/config';
88 | import apostrophe from '@apostrophecms/apostrophe-astro';
89 |
90 | // For production. You can use other adapters that support
91 | // `output: 'server'`
92 | import node from '@astrojs/node';
93 |
94 | export default defineConfig({
95 | output: 'server',
96 | adapter: node({
97 | mode: 'standalone'
98 | }),
99 | integrations: [
100 | apostrophe({
101 | aposHost: 'http://localhost:3000',
102 | widgetsMapping: './src/widgets',
103 | templatesMapping: './src/templates',
104 | viewTransitionWorkaround: false,
105 | includeResponseHeaders: [
106 | 'content-security-policy',
107 | 'strict-transport-security',
108 | 'x-frame-options',
109 | 'referrer-policy',
110 | 'cache-control'
111 | ],
112 | excludeRequestHeaders: [
113 | // For single-site setups or hosting on multiple servers, block the host header
114 | 'host'
115 | ]
116 | proxyRoutes: [
117 | // Custom URLs that should be proxied to Apostrophe.
118 | // Note that all of `/api/v1` is already proxied, so
119 | // this is usually unnecessary
120 | ]
121 | })
122 | ],
123 | vite: {
124 | ssr: {
125 | // Do not externalize the @apostrophecms/apostrophe-astro plugin, we need
126 | // to be able to use virtual: URLs there
127 | noExternal: [ '@apostrophecms/apostrophe-astro' ],
128 | }
129 | }
130 | });
131 | ```
132 |
133 | ## Options
134 |
135 | ### `aposHost` (mandatory)
136 |
137 | This option is the base URL of your Apostrophe instance. It must contain the
138 | port number if testing locally and/or communicating directly with another instance
139 | on the same server in a small production deployment. This option can be overriden
140 | at runtime with the `APOS_HOST` environment variable.
141 |
142 | During development it defaults automatically to: `http://localhost:3000`
143 |
144 | ### `widgetsMapping` (mandatory)
145 |
146 | The file in your project that contains the mapping between Apostrophe widget types and your Astro components (see below).
147 |
148 | ### `templatesMapping` (mandatory)
149 |
150 | The file in your project that contains the mapping between Apostrophe templates and your Astro templates (see below).
151 |
152 | ### `viewTransitionWorkaround` (optional)
153 |
154 | If set to `true`, Apostrophe will refresh its admin UI JavaScript on
155 | every page transition, to ensure compatibility with Astro
156 | [view transitions](https://docs.astro.build/en/guides/view-transitions/).
157 | If you are not using this feature of Astro, you can omit this flag to
158 | improve performance for editors. Ordinary website visitors are
159 | not impacted in any case. We are seeking an alternative solution to
160 | eliminate this option.
161 |
162 | ### `includeResponseHeaders`
163 |
164 | An array of HTTP headers that you want to include from Apostrophe to the final response sent to the browser - useful if you want to use an Apostrophe module like `@apostrophecms/security-headers` and want to keep those headers as configured in Apostrophe and to preserve Apostrophe's caching headers.
165 |
166 | At the present time, Astro is not compatible with the `nonce` property of `content-security-policy` `script-src` value. So this is automatically removed with that integration. The rest of the CSP header remains unchanged.
167 |
168 | ### `excludeRequestHeaders`
169 |
170 | An array of HTTP headers that you want to prevent from being forwarded from the browser to Apostrophe. This is particularly useful in single-site setups where you want to block the `host` header to allow Astro and Apostrophe to run on different hostnames.
171 |
172 | By default, all headers are forwarded except those specified in this array.
173 |
174 | ### `forwardHeaders` (deprecated)
175 |
176 | This option has been replaced by `includeResponseHeaders` which provides clearer naming for its purpose. If both options are provided, `includeResponseHeaders` takes precedence. `forwardHeaders` will be removed in a future version.
177 |
178 | ### Mapping Apostrophe templates to Astro components
179 |
180 | Since the front end of our project is entirely Astro, we'll need to create Astro components corresponding to each
181 | template that Apostrophe would normally render with Nunjucks.
182 |
183 | Create your template mapping in `src/templates/index.js` file.
184 | As shown above, this file path must then be added to your `astro.config.mjs` file,
185 | in the `templatesMapping` option of the `apostrophe` integration.
186 |
187 | ```js
188 | // src/templates/index.js
189 | import HomePage from './HomePage.astro';
190 | import DefaultPage from './DefaultPage.astro';
191 | import BlogIndexPage from './BlogIndexPage.astro';
192 | import BlogShowPage from './BlogShowPage.astro';
193 | import NotFoundPage from './NotFoundPage.astro';
194 |
195 | const templateComponents = {
196 | '@apostrophecms/home-page': HomePage,
197 | 'default-page': DefaultPage,
198 | '@apostrophecms/blog-page:index': BlogIndexPage,
199 | '@apostrophecms/blog-page:show': BlogShowPage,
200 | '@apostrophecms/page:notFound': NotFoundPage
201 | };
202 |
203 | export default templateComponents;
204 | ```
205 |
206 | #### How Apostrophe template names work
207 |
208 | For ordinary page templates, like the home page or a typical "default" page type
209 | in an Apostrophe project, you can just specify the Apostrophe module name.
210 |
211 | For special templates like `notFound`, and for modules that serve more than one
212 | template, you'll need to specify the complete name. For instance, Apostrophe's
213 | `@apostrophecms/blog` module contains an `@apostrophecms/blog-page` page type
214 | that renders an `index` template when viewing the main page of the blog, and
215 | a `show` template when viewing a single blog post (a "permalink" page).
216 |
217 | If you don't specify the template name, `:page` is assumed, which is just right
218 | for ordinary page types.
219 |
220 | For the "404 Not Found" page, use `@apostrophecms/page:notFound`, which is
221 | the standard name for this template in ApostropheCMS.
222 |
223 | #### Special template names
224 |
225 | The integration comes with two additional special template names that can be mapped to Astro templates.
226 | You should not add a module name to these special names:
227 |
228 | * `apos-fetch-error`: served when Apostrophe generates a 500-class error. The integration will set Astro's response status to 500.
229 | * `apos-no-template`: served when there is no mapping corresponding to the Apostrophe page type for this page.
230 |
231 | See below for an example Astro template for the `@apostrophe-cms/home-page` type. But first,
232 | let's look at widgets.
233 |
234 | ### Mapping Apostrophe widgets to Astro components
235 |
236 | Similar to Astro page components, Astro widget components replace Apostrophe's usual
237 | widget rendering.
238 |
239 | Create your template mapping in a file in your application, for example in a
240 | `src/widgets/index.js` file. This file path must then be added to your `astro.config.mjs` file,
241 | in the `widgetsMapping` option of the `apostrophe` integration, as seen above.
242 |
243 | ```js
244 | // src/widgets/index.js
245 |
246 | import RichTextWidget from './RichTextWidget.astro';
247 | import ImageWidget from './ImageWidget.astro';
248 | import VideoWidget from './VideoWidget.astro';
249 | import LayoutWidget from '@apostrophecms/apostrophe-astro/widgets/LayoutWidget.astro';
250 | import LayoutColumnWidget from '@apostrophecms/apostrophe-astro/widgets/LayoutColumnWidget.astro';
251 |
252 | const widgetComponents = {
253 | // Standard widgets, but we must provide our own Astro components for them
254 | '@apostrophecms/rich-text': RichTextWidget,
255 | '@apostrophecms/image': ImageWidget,
256 | '@apostrophecms/video': VideoWidget,
257 | '@apostrophecms/layout': LayoutWidget,
258 | '@apostrophecms/layout-column': LayoutColumnWidget
259 | };
260 |
261 | export default widgetComponents;
262 | ```
263 |
264 | > Note that even basic widget types like `@apostrophecms/image` do need an Astro
265 | template in your project. This integration does not currently ship with built-in
266 | Astro templates for all of the common Apostrophe widgets. However, all of the starter kits referenced in this document include all the necessary code for the most common core widgets.
267 |
268 | Note that the Apostrophe widget name (on the left) is the name of your widget module **without**
269 | the `-widget` part.
270 |
271 | > [!TIP]
272 | > The `@apostrophecms/layout-widget` needs some extra configuration and addition to areas in your ApostropheCMS project. You can read more in the [documentation](https://docs.apostrophecms.org/guide/core-widgets.html#layout-widget).
273 |
274 | The naming of your Astro widget templates is up to you. The above convention is just
275 | a suggestion.
276 |
277 | ### Creating the `[...slug.astro]` component and fetching Apostrophe data
278 |
279 | Since Apostrophe is responsible for managing URLs to content, including creating new content and pages
280 | on the fly, you will only need one top-level Astro page component: the `[...slug].astro` route.
281 |
282 | The integration comes with an `aposPageFetch` method that can be used to automatically
283 | fetch the relevant data for the current URL.
284 |
285 | Your `[...slug].astro` component should look like this:
286 |
287 | ```js
288 | ---
289 | import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js';
290 | import AposLayout from '@apostrophecms/apostrophe-astro/components/layouts/AposLayout.astro';
291 | import AposTemplate from '@apostrophecms/apostrophe-astro/components/AposTemplate.astro';
292 |
293 | const aposData = await aposPageFetch(Astro.request);
294 | const bodyClass = `myclass`;
295 |
296 | if (aposData.redirect) {
297 | return Astro.redirect(aposData.url, aposData.status);
298 | }
299 | if (aposData.notFound) {
300 | Astro.response.status = 404;
301 | }
302 | ---
303 |
304 |
305 |
306 |
307 |
308 |
309 |
310 |
311 | ```
312 |
313 | Thanks to the `aposPageFetch` call, the `aposData` object will then contain all of
314 | the information normally provided by `data` in an ApostropheCMS Nunjucks template.
315 | This includes, but is not limited to:
316 |
317 | * `page`: the page document for the current URL, if any
318 | * `piece`: the piece document when on a "show page" for a piece page type
319 | * `pieces`: an array of pieces when on an "index page" for a piece page type
320 | * `user`: information about the currently logged-in user
321 | * `global`: the ApostropheCMS global document e.g. global settings, editable global
322 | headers and footers, etc.
323 | * `query`: the `req.query` object, giving access to query parameters in the URL.
324 |
325 | Any other data that your custom Apostrophe code attaches to `req.data` is also
326 | available here.
327 |
328 | #### Understanding `AposLayout`
329 |
330 | This integration comes with a full managed global layout, replacing the `outerLayout.html`
331 | used in Nunjucks page templates.
332 |
333 | In your `[...slug].astro` file, use the `AposLayout` component built into this
334 | integration to leverage the global layout.
335 |
336 | To override any aspect of the global layout, take advantage of the following Astro slots,
337 | which are closely related to what ApostropheCMS offers in Nunjucks:
338 |
339 | * `startHead`: slot in the very beginning of the ``
340 | * `standardHead`: slot in the middle of ``, just after ``
341 | * `extraHead`: still in the HTML ``, at the very end
342 | * `startBody`: at the very beginning of the `` - this is not part of the refresh zone in edit mode
343 | * `beforeMain`: at the very beginning of the main body zone - part of the refresh zone in edit mode
344 | * `main`: the inner part of the main body zone - part of the refresh zone in edit mode
345 | * `afterMain`: at the very end of the main body zone - part of the refresh zone in edit mode
346 | * `endBody`: at the very end of the `` - this is not part of the refresh zone in edit mode
347 |
348 | In addition, the `AposLayout` component expects four props:
349 |
350 | * `aposData`: the data fetched from Apostrophe
351 | * `title`: this will go in the `` HTML tag
352 | * `lang` which will be set in the `` `lang` attribute
353 | * `bodyClass`: this will be added in the `class` attribute of the `` element
354 |
355 | This layout component will automatically manage the switch between support for
356 | the editing UI if a user is logged in and a simpler "Run Layout" for all other
357 | page requests.
358 |
359 | #### Understanding `AposTemplate`
360 |
361 | The role of `AposTemplate` is to automatically find the right Astro component
362 | to render based on the template mapping you created earlier. It accepts one
363 | prop, the full `aposData` object.
364 |
365 | ### Creating Astro page components
366 |
367 | Next we'll look at how to write Astro page components, such as the
368 | `src/templates/HomePage.astro` file mentioned above.
369 |
370 | > We do not recommend placing these in `src/pages` because their names are not
371 | > routes and Astro should not try to compile them as routes. Place them in
372 | > `src/templates` instead. `src/pages` should only contain the `[...slug.astro]` file.
373 |
374 | As an example, let's take a look at a simple home page template:
375 |
376 | ```js
377 | ---
378 | // src/templates/HomePage.astro
379 | import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
380 | const { page } = Astro.props.aposData;
381 | const { main } = page;
382 | ---
383 |
384 |
385 | { page.title }
386 |
387 |
388 | ```
389 |
390 | Notice that we receive the `page` object from Apostrophe, which gives us
391 | access to `page.title`. This is similar to `data.page` in a Nunjucks template.
392 |
393 | #### Understanding the `AposArea` component
394 |
395 | This component allows Astro to render Apostrophe areas, and provides a
396 | standard Apostrophe editing experience when doing so. Astro will automatically
397 | call our widget components once content exists in the area. All we have to do is
398 | pass on the area object, in this case the `main` schema field of `page`.
399 |
400 | Note that we can also pass area objects that are schema fields of widgets.
401 | This allows for nested widgets, such as multiple-column widgets often used
402 | for page layout.
403 |
404 | Note that additional props can be passed to the `AposArea` component and will be made
405 | accessible to widget components.
406 |
407 | ### Creating Astro widget components
408 |
409 | Earlier we created a mapping from Apostrophe widget names to Astro components.
410 | Let's take a look at how to implement these.
411 |
412 | You Astro widget will receive a `widget` property, in addition to any custom props
413 | you passed to the `AposArea` component. This `widget` property contains the
414 | the schema fields of your Apostrophe widget.
415 |
416 | As an example, here is a simple Astro component to render `@apostrophecms/image` widgets:
417 |
418 | ```js
419 | ---
420 | const { widget } = Astro.props;
421 | const placeholder = widget?.aposPlaceholder;
422 | const src = placeholder ?
423 | '/images/image-widget-placeholder.jpg' :
424 | widget?._image[0]?.attachment?._urls['full'];
425 | ---
426 |
431 |
432 | ```
433 |
434 | #### Placeholders are important in widgets that use them
435 |
436 | Why are we checking for `aposPlaceholder`? Apostrophe's `@apostrophecms/image`
437 | widget displays a placeholder image until the user clicks the edit pencil to
438 | select their image of choice. When rendered by Astro, Apostrophe still expects
439 | this to be the case. So we need to provide our own placeholder rendering.
440 |
441 | In this case, a suitably named file must exist in `public/images` in our Astro project.
442 |
443 | #### Remember, relationship properties might not be populated
444 |
445 | It is always possible that the image associated with an image widget has
446 | been archived. The `?.` syntax is a simple way to avoid a 500 error
447 | in such a situation. You may wish to add a more sophisticated fallback.
448 |
449 | ### Accessing image and URLs
450 |
451 | Properties like `.attachment._urls['full']` exist on all image pieces,
452 | while properties like `.attachment._url` exist on non-image attachments
453 | such as PDFs. For more information, see
454 | the [attachment field format](https://v3.docs.apostrophecms.org/reference/api/field-formats.html#attachment).
455 |
456 | ## What to change in your Apostrophe project
457 |
458 | Nothing! Well, almost.
459 |
460 | * Your project must be using Apostrophe 4.x.
461 | * You'll need to `npm update` your project to the latest version of `apostrophe`.
462 | * You'll need to set the `APOS_EXTERNAL_FRONT_KEY` environment variable to a secret
463 | value of your choosing when running Apostrphe.
464 | * Make sure you set that **same value** when running your Astro project.
465 | * To avoid developer confusion, we recommend changing any page templates in your
466 | Apostrophe project to provide a link to your Astro frontend site and
467 | remove all other output. Everyone, editors included, should go straight to Astro.
468 |
469 | ## Starting up your combined project
470 |
471 | To start your Astro project, follow the usual practice:
472 |
473 | ```bash
474 | cd my-astro-project
475 | npm install
476 | export APOS_EXTERNAL_FRONT_KEY=your-secret-goes-here
477 | npm run dev
478 | ```
479 |
480 | In an adjacent terminal, start your Apostrophe project:
481 |
482 | ```bash
483 | cd my-apostrophe-project
484 | npm install
485 | export APOS_EXTERNAL_FRONT_KEY=your-secret-goes-here
486 | npm run dev
487 | ```
488 |
489 | For convenience, Astro generally defaults to port `4321`, while
490 | Apostrophe defaults to port `3000`.
491 |
492 | ## Logging in
493 |
494 | Once your integration is complete, you will be able to reach the login page in
495 | the usual way at `http://localhost:4321/login`. Astro proxies this route directly
496 | to Apostrophe. Therefore any additional extensions you have added such as
497 | Apostrophe's hCaptcha and TOTP modules will work as expected.
498 |
499 | ## Redirections
500 |
501 | When Apostrophe sends a response as a redirection, you will receive a specially
502 | formatted `aposData` object containing `redirect: true`, a `url` property for the url
503 | to redirect to, and a `status` for the redirection HTTP status code. This is handled
504 | in the earlier example, repeated here for convenience:
505 |
506 | ```js
507 | const aposData = await aposPageFetch(Astro.request)
508 | // Redirect
509 | if (aposData.redirect) {
510 | return Astro.redirect(aposData.url, aposData.status);
511 | }
512 | ```
513 |
514 | ## 404 Not Found
515 |
516 | Much like the redirect case, when Apostrophe determines that the page was not
517 | found, `aposData.notFound` will be set to true. The example `[...slug].astro`
518 | file provided above includes logic to set Astro's status code to 404 in this
519 | situation.
520 |
521 | ## Reserved routes
522 |
523 | As this integration proxies certain Apostrophe endpoints, there are some routes that are taken by those endpoints:
524 |
525 | * `/apos-frontend/[...slug]` for serving Apostrophe assets
526 | * `/uploads/[...slug]` for serving Apostrophe uploaded assets
527 | * `/api/v1/[...slug]` and `/[locale]/api/v1/[...slug]` for Apostrophe API endpoints
528 | * `/login` and `/[locale]/login` for the login page
529 |
530 | As all Apostrophe API endpoints are proxied, you can expose new api routes as usual in your Apostrophe modules, and be able to request them through your Astro application.
531 | Those proxies are forwarding all of the original request headers, such as cookies, so that Apostrophe login works normally.
532 |
533 | ## What about widget players?
534 |
535 | ApostropheCMS is very unopinionated on the front end, but it does include one
536 | important front end feature: widget players. These provide a way for developers
537 | to provide special behavior to widgets, calling each widget's player exactly
538 | once at page load and when new widgets are inserted or replaced with new values.
539 | Users appreciate this and expect interactive widget features to work normally
540 | without a page refresh, even if the widget was just added to the page.
541 |
542 | In Astro, web components are a recommended strategy to achieve the same thing.
543 | Defining and using a web component in an Astro widget component has much
544 | the same effect as defining a widget player in a standalone Apostrophe project.
545 |
546 | Here is a simple outline of such a web component. For a complete example of
547 | the same widget, check out the source code of `VideoWidget.astro` in our
548 | [Astro Essentials Starter Kit](https://github.com/apostrophecms/starter-kit-astro-essentials/blob/main/frontend/src/widgets/VideoWidget.astro) project.
549 |
550 | ```js
551 | ---
552 | // src/widgets/VideoWidget.astro
553 | const { widget } = Astro.props;
554 | const placeholder = widget?.aposPlaceholder ? 'true' : '';
555 | const url = widget?.video?.url;
556 | ---
557 |
562 |
565 |
566 |
584 | ```
585 |
586 | > Note that Astro script tags aren't really plain vanilla HTML script tags.
587 | > They are efficiently compiled, support TypeScript and are only executed
588 | > once even if the component appears may times on the page. Defining a
589 | > web component allows us to leverage that code more than once by using
590 | > the newly defined element as often as we wish.
591 |
592 | ## `aposSetQueryParameter`: working with query parameters
593 |
594 | One last thing: query parameters. Sometimes we want to create pagination
595 | links with page numbers, add filters to a URL's query string, and so on.
596 | But, working with query parameters coming from Apostrophe can
597 | be a little bit tricky because there are often special query parameters
598 | present during editing that should not be part of a visible URL.
599 |
600 | As a convenience, Apostrophe provides `aposSetQueryParameter` to abstract
601 | all that away.
602 |
603 | Here is how the `BlogIndexPage.astro` component of the
604 | [Starter Kit Astro Essentials](https://github.com/apostrophecms/starter-kit-astro-essentials/blob/main/frontend/src/templates/BlogIndexPage.astro) project generates
605 | links to each page of blog posts:
606 |
607 | ```js
608 | ---
609 | import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js';
610 |
611 | const {
612 | pieces,
613 | currentPage,
614 | totalPages
615 | } = Astro.props.aposData;
616 |
617 | const pages = [];
618 | for (let i = 1; (i <= totalPages); i++) {
619 | pages.push({
620 | number: i,
621 | current: page === currentPage,
622 | url: setParameter(Astro.url, 'page', i)
623 | });
624 | }
625 | ---
626 |
627 |
628 | { page.title }
629 |
630 | Blog Posts
631 |
632 | {pieces.map(piece => (
633 |
636 | ))}
637 |
638 | {pages.map(page => (
639 | {page.number}
642 |
643 | ))}
644 |
645 | ```
646 |
647 | Imported here as `setParameter`, `aposSetQueryParameter` allows
648 | us to do two things:
649 |
650 | 1. Take a URL and return a new URL with a certain query parameter set
651 | to a new value.
652 | 2. Remove a query parameter completely by passing the empty string as
653 | a value, or by passing `null` or `undefined`.
654 |
655 | While you can get the same result by manipulating `Astro.url` yourself,
656 | you'll be able to avoid the confusing presence of query parameters
657 | like `aposMode` by using this convenient feature.
658 |
659 | ## What about Vue, React, SvelteJS, etc.?
660 |
661 | While not shown directly in the examples above, **Astro can import components
662 | written in any of these frameworks.** Just use `astro add` to install
663 | the appropriate integration, then `import` your components freely in your
664 | `.astro` files. For complete documentation and examples, see the
665 | [`@astrojs/react` integration](https://docs.astro.build/en/guides/integrations-guide/react/).
666 |
667 | In this way, Astro acts as a **universal bridge** to essentially all modern
668 | frontend frameworks.
669 |
670 | ## A note on production use
671 |
672 | For production use, any Astro hosting adapter that supports `mode: 'server'` should
673 | be acceptable. In particular, our [Starter Kit Astro Essentials](https://github.com/apostrophecms/starter-kit-astro-essentials) project comes pre-configured
674 | for the `node` adapter, and includes `npm run build` and `npm run serve`
675 | support to take advantage of that. In `server` mode there is not a great
676 | deal of difference between these and `npm run dev`, but there is less
677 | overhead and less information exposed to the public, so we recommend following
678 | this best practice.
679 |
680 | ## Debugging
681 |
682 | In most cases, Astro prints helpful error messages directly in the browser
683 | when in a development environment.
684 |
685 | However, if you receive the following error:
686 |
687 | ```
688 | Only URLs with a scheme in: file and data are supported by the default ESM
689 | loader. Received protocol 'virtual:'
690 | ```
691 |
692 | Then you most likely left out this part of the above `astro.config.mjs` file:
693 |
694 | ```javascript
695 | export default defineConfig({
696 | // ... other settings above here ...
697 | vite: {
698 | ssr: {
699 | // Do not externalize the @apostrophecms/apostrophe-astro plugin, we need
700 | // to be able to use virtual: URLs there
701 | noExternal: [ '@apostrophecms/apostrophe-astro' ],
702 | }
703 | }
704 | });
705 | ```
706 |
707 | Without this logic, the `virtual:` URLs used to access configuration information
708 | will cause the build to fail.
709 |
710 | ## Enabling the `render-area` option to ApostropheCMS REST APIs
711 |
712 | In order to enable section template library previews, and also unlock the `?render-area=1` and `?render-area=inline` query parameters to ApostropheCMS REST APIs in general, you'll need to add the following route:
713 |
714 | ```markup
715 | ---
716 | // Place this file in: src/pages/api/apos-external-front/render-area.astro
717 |
718 | import AposRenderAreaForApi from '@apostrophecms/apostrophe-astro/components/AposRenderAreaForApi.astro';
719 | ---
720 |
721 | ```
722 |
723 | This file provides a "bridge" between ApostropheCMS and Astro, allowing ApostropheCMS to "call back" to the Astro project to render the content for a particular area.
724 |
725 | Our recently updated starter kits already include this file.
726 |
727 | ## Enabling the `@apostrophecms/layout-widget` in an existing project
728 | If you are using any of our starter kits, or you are following the integration steps outlined above, you will have the core layout-widget installed. For existing projects you will have a few steps to activate it.
729 |
730 | ### Backend updates
731 | 1. Add `@apostrophecms/layout` to any areas where you will want to add the widget.
732 |
733 | By default, the layout widget columns will include the core rich-text, image, and video widgets. If you want any additional widget types, you will have to follow several additional steps:
734 |
735 | 1. Create a `backend/modules/@apostrophecms/layout-column-widget/index.js` file.
736 | 2. Add the following code:
737 | ```javascript
738 | export default {
739 | fields(self, options) {
740 | return {
741 | add: {
742 | content: {
743 | type: 'area',
744 | label: 'Main Content',
745 | options: {
746 | widgets: {
747 | // add any project-specific content widgets
748 | // nesting layout widgets can lead to poor performance
749 | // or rendering issues
750 | '@apostrophecms/rich-text': {},
751 | '@apostrophecms/image': {},
752 | '@apostrophecms/video': {}
753 | }
754 | }
755 | }
756 | }
757 | };
758 | }
759 | };
760 | ```
761 |
762 | This file extends the default column widget to define which content widgets editors can add inside each column. Avoid nesting layout widgets inside other layouts to prevent excessive DOM complexity and performance issues.
763 |
764 | > [!TIP]
765 | > You can read more about configuring and using the layout-widget in the [documentation](https://docs.apostrophecms.org/guide/core-widgets.html#layout-widget).
766 |
767 | ### Frontend updates
768 | The `@apostrophecms/apostrophe-astro` package contains templates for the layout widget and column, but like the other widgets, they have to be mapped to the corresponding Apostrophe widgets.
769 |
770 | 1. Open the `frontend/src/widgets/index.js` file.
771 | 2. Import the `layout` and `layout-column` widgets
772 | ``` javascript
773 | import LayoutWidget from '@apostrophecms/apostrophe-astro/widgets/LayoutWidget.astro';
774 | import LayoutColumnWidget from '@apostrophecms/apostrophe-astro/widgets/LayoutColumnWidget.astro';
775 | ```
776 | 3. Map the components in the `widgetComponents` object
777 | ``` javascript
778 | export const widgetComponents = {
779 | ...widgetComponents,
780 | '@apostrophecms/layout': LayoutWidget,
781 | '@apostrophecms/layout-column': LayoutColumnWidget
782 | };
783 | ```
784 | Once you’ve added these mappings, restart your Apostrophe server and refresh the editor. The layout widget should now appear as an option in any area that includes @apostrophecms/layout.
785 |
786 | ## Conclusion
787 |
788 | This module provides a new way to use ApostropheCMS: as a back end
789 | for modern front end development in Astro. But more than that, it
790 | provides a future-proof bridge to many different front-end frameworks.
791 |
792 | Also important, Apostrophe fully maintains the on-page, in-context editing
793 | experience when integrated with Astro, going beyond "side-by-side"
794 | editing experiences to achieve integration close enough that we often
795 | have to look at the address bar to know whether we are looking at
796 | Astro or Apostrophe.
797 |
798 | That being said, this integration is also new, and we encourage you
799 | to share your feedback.
800 |
801 | ## Acknowledgements
802 |
803 | Development of this module began with Stéphane Maccari and Clément Ravier of
804 | Michelin. We are grateful for their generous support of ApostropheCMS.
805 |
--------------------------------------------------------------------------------