Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
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
}
19 changes: 16 additions & 3 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 @@ -68,7 +68,8 @@ 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 PLP 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.
* `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`.
Expand All @@ -88,7 +89,9 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s
}
}
```
3. [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)
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
Expand All @@ -100,6 +103,9 @@ 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
```
6. **Enable Automated Triggers**: Once you've confirmed that all actions work correctly, uncomment the triggers and rules sections in `app.config.yaml`:
```yaml
Expand All @@ -116,6 +122,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,6 +136,9 @@ 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`
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:
Expand Down
146 changes: 109 additions & 37 deletions actions/categories.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,8 @@ governing permissions and limitations under the License.

*/

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

const MAX_TREE_DEPTH = 3;

Expand All @@ -32,7 +28,8 @@ function hasFamilies(families) {
}

/**
* Resolves all category slugs belonging to the given ACO category families.
* 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.
Expand All @@ -42,63 +39,137 @@ function hasFamilies(families) {
* 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<string[]>} Flat array of all unique category slugs.
* @returns {Promise<Map<string, Object>>} Map of category slug to category metadata.
*/
async function getCategorySlugsFromFamilies(context, families) {
console.debug("Getting category slugs from families:", families);
const allSlugs = new Set();
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) {
console.debug("Getting category slugs from family:", family);
logger.debug('Getting category data from family:', family);
// Get root-level categories for this family
const firstLevel = await requestSaaS(
CategoryTreeQuery,
"getCategoryTree",
{ family },
context,
);
const firstLevel = await requestSaaS(CategoryTreeQuery, 'getCategoryTree', { family }, context);

let pending = [];
for (const cat of firstLevel.data.categoryTree) {
allSlugs.add(cat.slug);
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) allSlugs.add(slug);
for (const slug of pending) {
if (!categoryMap.has(slug)) categoryMap.set(slug, null);
}

const childrenRes = await requestSaaS(
CategoryTreeBySlugsQuery,
"getCategoryTreeBySlugs",
'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) {
allSlugs.add(cat.slug);
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 (!allSlugs.has(child)) pending.push(child);
if (!categoryMap.has(child)) pending.push(child);
}
}
}
}
console.debug("Category slugs resolved:", [...allSlugs]);
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()];
}

return [...allSlugs];
/**
* 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 ACCS categories grouped by level.
* 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
Expand All @@ -108,19 +179,20 @@ async function getCategorySlugsFromFamilies(context, families) {
* @returns {Promise<string[][]>} Sparse array where index N holds urlPath strings at level N.
*/
async function getCategories(context) {
const categoriesRes = await requestSaaS(
CategoriesQuery,
"getCategories",
{},
context,
);
const categoryMap = await getCategoryMap(context);
const byLevel = [];
for (const { urlPath, level } of categoriesRes.data.categories) {
const idx = parseInt(level);
byLevel[idx] = byLevel[idx] || [];
byLevel[idx].push(urlPath);
for (const [, { slug, level }] of categoryMap) {
byLevel[level] = byLevel[level] || [];
byLevel[level].push(slug);
}
return byLevel;
}

module.exports = { getCategorySlugsFromFamilies, getCategories, hasFamilies };
module.exports = {
getCategorySlugsFromFamilies,
getCategoryMapFromFamilies,
getCategoryMap,
getCategories,
hasFamilies,
buildBreadcrumbs,
};
Loading
Loading