├── .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 |
12 | 13 |
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 |
40 | 41 |
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 &&