Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/commands/check-aio-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Run `bash tools/check-errors.sh $ARGUMENTS` and evaluate the results, summarizing the error types, affected actions, and likely root causes.
7 changes: 7 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"WebFetch(domain:www.aem.live)"
]
}
}
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,10 @@ logs
# aem-commerce-prerender related
*.env
*.aio.json
.aem-commerce-prerender.json
.aem-commerce-prerender.json

# AI
.cursor/

# LOCAL_FS=true files
local-data/
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
**/*.yaml
**/*.hbs
7 changes: 7 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"tabWidth": 2,
"semi": true
}
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Project Rules

## App Builder Deployments

Use `npm run deploy` instead of `aio app deploy` for all App Builder deployments.
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AEM Commerce Prerender

The AEM Commerce Prerenderer is a tool to generate static product detail pages from dynamic data sources like Adobe Commerce Catalog Service for publishing via [AEM Edge Delivery Services](https://www.aem.live/). It integrates with [BYOM (Bring Your Own Markup)](https://www.aem.live/docs/byo-markup) and EDS indexes to deliver fast, SEO-friendly pages.
The AEM Commerce Prerenderer is a tool to generate static product detail pages (PDPs) and product listing pages (PLPs) from dynamic data sources like Adobe Commerce Catalog Service for publishing via [AEM Edge Delivery Services](https://www.aem.live/). It integrates with [BYOM (Bring Your Own Markup)](https://www.aem.live/docs/byo-markup) and EDS indexes to deliver fast, SEO-friendly pages.

## Key Benefits

Expand Down Expand Up @@ -69,10 +69,12 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s
* `PRODUCTS_TEMPLATE`: The URL for the product template page (auto-populated by wizard). For localized sites with URLs like `https://main--site--org.aem.page/en-us/products/default`, you can use the `{locale}` token: `https://main--site--org.aem.page/{locale}/products/default`
* `PRODUCT_PAGE_URL_FORMAT`: The URL pattern for product pages (auto-populated by wizard). Supports tokens: `{locale}`, `{urlKey}`, `{sku}`. Default pattern: `/{locale}/products/{urlKey}`. For live environments, consider using a different prefix like `/{locale}/products-prerendered/{urlKey}` for logical separation
* `LOCALES`: Comma-separated list of locales (e.g., `en-us,en-gb,fr-fr`) or empty for non-localized sites
* `ACO_CATEGORY_FAMILIES`: *(Commerce Optimizer only)* Comma-separated list of category family identifiers (e.g., `electronics,apparel`). Determines which categories are included for both PLP and PDP pre-rendering. For PDP pre-rendering, catalogs with 10,000 or fewer products are fetched in full regardless of this setting. For larger catalogs, the system fetches the first 10,000 products plus up to 10,000 per category discovered from these families, deduplicated by SKU. If not configured, only the first 10,000 products are pre-rendered.
* `PLP_PRODUCTS_PER_PAGE`: Number of products to display per category listing page (default: `9`)
* `AEM_ADMIN_API_AUTH_TOKEN`: Long-lived authentication token for AEM Admin API (valid for 1 year). During setup, the wizard will exchange your temporary 24-hour token from [admin.hlx.page](https://admin.hlx.page/) for this long-lived token automatically.

You can modify the environment-specific variables by editing the `.env` file directly or by re-running the setup wizard with `npm run setup`.
1. **After Setup Completion**: Once the setup process is complete, the following configurations will be automatically applied:
2. **After Setup Completion**: Once the setup process is complete, the following configurations will be automatically applied:

* **Site Context**: A Site Context will be created and stored in your localStorage. This serves as the authentication medium required to operate the [Storefront Prerender Management UI](https://prerender.aem-storefront.com) (you will be redirected to this address).

Expand All @@ -88,9 +90,11 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s
}
}
```
1. [Customize the code](/docs/CUSTOMIZE.md) that contains the rendering logic according to your requirements, for [structured data](/actions/pdp-renderer/ldJson.js), [markup](/actions/pdp-renderer/render.js) and [templates](https://github.com/adobe-rnd/aem-commerce-prerender/tree/main/actions/pdp-renderer/templates)
1. Deploy the solution with `npm run deploy`
1. **Testing Actions Manually**: Before enabling automated triggers, verify that each action works correctly by invoking them manually:
3. [Customize the code](/docs/CUSTOMIZE.md) that contains the rendering logic according to your requirements:
* **PDP**: [structured data](/actions/pdp-renderer/ldJson.js), [markup](/actions/pdp-renderer/render.js) and [templates](https://github.com/adobe-rnd/aem-commerce-prerender/tree/main/actions/pdp-renderer/templates)
* **PLP**: [structured data](/actions/plp-renderer/ldJson.js), [markup](/actions/plp-renderer/render.js) and [templates](https://github.com/adobe-rnd/aem-commerce-prerender/tree/main/actions/plp-renderer/templates)
4. Deploy the solution with `npm run deploy`
5. **Testing Actions Manually**: Before enabling automated triggers, verify that each action works correctly by invoking them manually:
```bash
# Fetch all products from Catalog Service and store them in default-products.json
aio rt action invoke aem-commerce-ssg/fetch-all-products
Expand All @@ -100,8 +104,11 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s

# Clean up and unpublish deleted products
aio rt action invoke aem-commerce-ssg/mark-up-clean-up

# Render all category listing pages
aio rt action invoke aem-commerce-ssg/render-all-categories
```
1. **Enable Automated Triggers**: Once you've confirmed that all actions work correctly, uncomment the triggers and rules sections in `app.config.yaml`:
6. **Enable Automated Triggers**: Once you've confirmed that all actions work correctly, uncomment the triggers and rules sections in `app.config.yaml`:
```yaml
triggers:
productPollerTrigger:
Expand All @@ -116,6 +123,10 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s
feed: "/whisk.system/alarms/interval"
inputs:
minutes: 60
renderAllCategoriesTrigger:
feed: "/whisk.system/alarms/interval"
inputs:
minutes: 15
rules:
productPollerRule:
trigger: "productPollerTrigger"
Expand All @@ -126,9 +137,12 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s
markUpCleanUpRule:
trigger: "markUpCleanUpTrigger"
action: "mark-up-clean-up"
renderAllCategoriesRule:
trigger: "renderAllCategoriesTrigger"
action: "render-all-categories"
```
Then redeploy the solution: `npm run deploy`
1. **Management UI Overview**: Navigate to the [Storefront Prerender Management UI](https://prerender.aem-storefront.com) to monitor and manage your prerender deployment. The UI provides several tabs:
7. **Management UI Overview**: Navigate to the [Storefront Prerender Management UI](https://prerender.aem-storefront.com) to monitor and manage your prerender deployment. The UI provides several tabs:

* **Published Products** (`#/products`): Displays the list of products published on your store, as retrieved from your site's `published-products-index.json`. For sites with over a thousand products, use the pagination interface to navigate through results. The search functionality allows you to filter products on the current page.

Expand All @@ -149,7 +163,7 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s

* **Settings** (`#/settings`): Allows you to access and modify your personal context file. The context file contains information about the prerender app's namespace, authentication token, and the currently active Helix token. Editing the context file enables you to use the prerender UI to manage other App Builder applications.

1. The system is now up and running. In the first cycle of operation, it will publish all products in the catalog. Subsequent runs will only process products that have changed.
8. The system is now up and running. In the first cycle of operation, it will publish all products in the catalog. Subsequent runs will only process products that have changed.

### Management UI Setup

Expand Down
198 changes: 198 additions & 0 deletions actions/categories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/*

Copyright 2026 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.

*/

const { requestSaaS } = require('./utils');
const { CategoriesQuery, CategoryTreeQuery, CategoryTreeBySlugsQuery } = require('./queries');

const MAX_TREE_DEPTH = 3;

/**
* Checks whether category families are configured (not the [null] default).
*
* @param {Array} families - The categoryFamilies array from runtime config.
* @returns {boolean}
*/
function hasFamilies(families) {
return Array.isArray(families) && families.length > 0;
}

/**
* Resolves all categories belonging to the given ACO category families,
* returning a Map of slug → full category metadata.
*
* Uses BFS traversal of the categoryTree API:
* 1. Query each family's root categories and their immediate childrenSlugs.
* 2. Query those children (with depth) to retrieve their descendants.
* 3. Repeat until no unresolved childrenSlugs remain.
*
* Handles trees of arbitrary depth even when the API caps depth at
* MAX_TREE_DEPTH per call — each iteration advances up to that many levels.
*
* Shared by getCategorySlugsFromFamilies and getCategoryMapFromFamilies.
*
* @param {Object} context - Request context (config, logger, headers, etc.).
* @param {string[]} families - ACO category family identifiers.
* @returns {Promise<Map<string, Object>>} Map of category slug to category metadata.
*/
async function fetchCategoryTree(context, families) {
const { logger } = context;
logger.debug('Getting category data from families:', families);
const categoryMap = new Map();

for (const family of families) {
logger.debug('Getting category data from family:', family);
// Get root-level categories for this family
const firstLevel = await requestSaaS(CategoryTreeQuery, 'getCategoryTree', { family }, context);

let pending = [];
for (const cat of firstLevel.data.categoryTree) {
categoryMap.set(cat.slug, cat);
pending.push(...(cat.childrenSlugs || []));
}

// BFS: resolve children level by level until no new slugs remain
while (pending.length) {
// Mark pending as seen before querying to prevent re-processing
for (const slug of pending) {
if (!categoryMap.has(slug)) categoryMap.set(slug, null);
}

const childrenRes = await requestSaaS(
CategoryTreeBySlugsQuery,
'getCategoryTreeBySlugs',
{ family, slugs: pending, depth: MAX_TREE_DEPTH },
context,
);

// First pass: capture any descendant slugs included due to depth traversal
for (const cat of childrenRes.data.categoryTree) {
categoryMap.set(cat.slug, cat);
}

// Second pass: collect only new childrenSlugs for next iteration
pending = [];
for (const cat of childrenRes.data.categoryTree) {
for (const child of cat.childrenSlugs || []) {
if (!categoryMap.has(child)) pending.push(child);
}
}
}
}
logger.debug('Category slugs resolved:', [...categoryMap.keys()]);

return categoryMap;
}

/**
* Resolves all category slugs belonging to the given ACO category families.
*
* Uses BFS traversal of the categoryTree API via fetchCategoryTree.
*
* @param {Object} context - Request context (config, logger, headers, etc.).
* @param {string[]} families - ACO category family identifiers.
* @returns {Promise<string[]>} Flat array of all unique category slugs.
*/
async function getCategorySlugsFromFamilies(context, families) {
const categoryMap = await fetchCategoryTree(context, families);
return [...categoryMap.keys()];
}

/**
* Resolves all categories with full metadata from the given ACO category families.
*
* Uses BFS traversal of the categoryTree API via fetchCategoryTree.
*
* @param {Object} context - Request context (config, logger, headers, etc.).
* @param {string[]} families - ACO category family identifiers.
* @returns {Promise<Map<string, Object>>} Map of category slug to category metadata.
*/
async function getCategoryMapFromFamilies(context, families) {
return fetchCategoryTree(context, families);
}

/**
* Converts a slug segment to a category name if a name is not provided.
* E.g. "computers-tablets" → "Computers Tablets"
*
* Callers should always prefer category.name from the API response.
*/
function getCategoryNameFromSlug(segment) {
return segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

/**
* Derives breadcrumb trail from a category slug path.
*
* @param {string} slug - The category slug (e.g. "electronics/computers-tablets/laptops").
* @param {Map<string, Object>} categoryMap - The category map for name resolution.
* @returns {Array<{name: string, slug: string}>} Breadcrumb entries.
*/
function buildBreadcrumbs(slug, categoryMap) {
const segments = slug.split('/');
const breadcrumbs = [];

for (let i = 0; i < segments.length; i++) {
const ancestorSlug = segments.slice(0, i + 1).join('/');
const category = categoryMap.get(ancestorSlug);
const name = category?.name || getCategoryNameFromSlug(segments[i]);
breadcrumbs.push({ name, slug: ancestorSlug });
}

return breadcrumbs;
}

/**
* Retrieves all categories as a Map of urlPath → category metadata.
*
* @param {Object} context - Request context (config, logger, headers, etc.).
* @returns {Promise<Map<string, Object>>} Map of urlPath to category metadata.
*/
async function getCategoryMap(context) {
const categoriesRes = await requestSaaS(CategoriesQuery, 'getCategories', {}, context);
const categoryMap = new Map();
for (const { name, level, urlPath } of categoriesRes.data.categories) {
if (!urlPath) continue;
categoryMap.set(urlPath, { slug: urlPath, name, level: parseInt(level) });
}
return categoryMap;
}

/**
* Retrieves all categories grouped by level.
*
* Returns a sparse array indexed by category level so callers can iterate
* shallowest levels first (used for the early-exit optimization when
* fetching products by category).
*
* @param {Object} context - Request context (config, logger, headers, etc.).
* @returns {Promise<string[][]>} Sparse array where index N holds urlPath strings at level N.
*/
async function getCategories(context) {
const categoryMap = await getCategoryMap(context);
const byLevel = [];
for (const [, { slug, level }] of categoryMap) {
byLevel[level] = byLevel[level] || [];
byLevel[level].push(slug);
}
return byLevel;
}

module.exports = {
getCategorySlugsFromFamilies,
getCategoryMapFromFamilies,
getCategoryMap,
getCategories,
hasFamilies,
buildBreadcrumbs,
};
14 changes: 11 additions & 3 deletions actions/check-product-changes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ governing permissions and limitations under the License.
*/

const { Core, State, Files } = require('@adobe/aio-sdk');
const { localFilesLib } = require('../lib/localFilesLib');
const { poll } = require('./poller');
const { StateManager } = require('../lib/state');
const { ObservabilityClient } = require('../lib/observability');
const { getRuntimeConfig } = require('../lib/runtimeConfig');
const { handleActionError } = require('../lib/errorHandler');

// Must match timeout in app.config.yaml. The mutex TTL is derived from this so the
// lock auto-expires if the runtime kills the process before the finally block runs.
const ACTION_TIMEOUT_MS = 10800000; // 3 hours

/**
* Entry point for the "Product changes check" action.
* @param {Object} params
Expand All @@ -38,8 +43,11 @@ async function main(params) {
});

// Init SDK libs and state manager
const stateLib = await State.init(params.libInit || {});
const filesLib = await Files.init(params.libInit || {});
const isLocal = !!params.LOCAL_FS;
const stateLib = isLocal
? { get: async () => null, put: async () => {}, delete: async () => {} }
: await State.init(params.libInit || {});
const filesLib = isLocal ? localFilesLib : await Files.init(params.libInit || {});
const stateMgr = new StateManager(stateLib, { logger });

let activationResult;
Expand All @@ -61,7 +69,7 @@ async function main(params) {

try {
// Mark job as running with TTL to avoid permanent lock on unexpected failures
await stateMgr.put('running', 'true', { ttl: 3600 });
await stateMgr.put('running', 'true', { ttl: ACTION_TIMEOUT_MS / 1000 });

// Core logic
activationResult = await poll(cfg, { stateLib: stateMgr, filesLib }, logger);
Expand Down
Loading
Loading