Developer Demo
Categories & Taxonomy
_TaxonomyTerm documents, and each content item carries its assignments as URI references on _itemMetadata.categories.Why categories and not a property#
This demo still carries the older approach alongside the new one, so you can compare them directly. ArticlePage.category is a string enum declared on one content type; the CMS taxonomy is a tree that any content type can point at.
Key Things to Know#
- →Cross-type. One category applies to articles, case studies and marketing pages alike. A per-type enum needs redeclaring on every type, and its facets can never be combined.
- →Editor-managed. Adding a term is a CMS action in Settings > Categories. Adding an enum value is a code change plus an
opti:pushto every instance. - →Hierarchical. Terms have parents, so you get browsable paths like Product > Savings > ISAs. An enum is flat.
- →No content type change.
categoriesis a built-in property on a content version. There is nothing to declare and nothing to push.
The term tree#
Each category is its own document in Graph. There is no nested structure in the index: you fetch the flat list and rebuild the tree from parent. Terms marked grouping below are not selectable, so an editor can use them to organise the picker without anyone being able to tag content with a bare “Product”.
# Every category is indexed as its own _TaxonomyTerm document.
# The tree is reconstructed client-side from the parent field.
query GetTaxonomyTerms($locale: [Locales]) {
_TaxonomyTerm(limit: 100, locale: $locale) {
total
items {
_metadata {
key # "mortgages" - the stable API identifier
taxonomy # "categories" - the only taxonomy that exists today
displayName # "Mortgages" - localized, editor-facing
description
usage # "Public" | "Internal"
parent # parent term key, or null for a root
}
}
}
}
# NOTE: the public docs list key/displayName/description/usage/taxonomy but
# omit parent. It IS in the schema - without it there is no way to render a
# hierarchy, only a flat list.- ■Audienceaudiencegrouping
- └Business Bankingbusiness_bankinggrouping
- └Established SMEsestablished_sme
- └Startupsstartups
- └Personal Financepersonal_financegrouping
- └Familiesfamilies
- └First-time Buyersfirst_time_buyers
- └Retirement Plannersretirement_planners
- ■Editorial Lifecyclelifecyclegrouping
- └Campaign 2026campaign_2026
- └Evergreenevergreen
- └Needs Reviewneeds_review
- ■Productproductgrouping
- └Borrowingborrowinggrouping
- └Credit Cardscredit_cardsgrouping
- └Business Credit Cardsbusiness_credit_cards
- └Personal Credit Cardspersonal_credit_cards
- └Loans and Overdraftsloansgrouping
- └Business Lendingbusiness_lending
- └Overdraftsoverdrafts
- └Personal Loanspersonal_loans
- └Mortgagesmortgagesgrouping
- └Buy to Letbuy_to_let
- └First-time Buyer Mortgagesfirst_time_buyer_mortgage
- └Overpaymentsoverpayments
- └Remortgagingremortgaging
- └Business Productsbusinessgrouping
- └Business Accountsbusiness_accountsgrouping
- └Business Current Accountbusiness_current_account
- └Merchant Servicesmerchant_services
- └Everyday Bankingeverydaygrouping
- └Current Accountscurrent_accountsgrouping
- └Instant Paymentsinstant_payments
- └Mobile Bankingmobile_banking
- └Foreign Exchangeforeign_exchangegrouping
- └International Paymentsinternational_payments
- └Travel Moneytravel_money
- └Saving and Investingsaving_investinggrouping
- └Investmentsinvestmentsgrouping
- └General Investment Accountgeneral_investment
- └ISAsisasgrouping
- └Cash ISAcash_isa
- └Junior ISAjunior_isa
- └Stocks and Shares ISAstocks_isa
- └Pensionspensions
- └Savingssavingsgrouping
- └Easy Accesseasy_access
- └Fixed Ratefixed_rate
- ■Topictopicgrouping
- └About the Companycompany
- └Customer Storiescustomer_stories
- └Help and Supportsupport
- └How-to Guidesguides
- └Market Insightsmarket_insights
- └Rates and Feesrates_and_fees
Categories on content#
The single most common mistake here is reaching for _metadata. Assignments live on _itemMetadata, and they are URI strings rather than display names, so a browse UI always needs the term documents too.
# Assigned categories live on _itemMetadata, NOT _metadata.
#
# _Content._metadata is typed IContentMetadata -> has NO categories field
# _Content._itemMetadata is typed _Metadata -> has categories: [String]
#
# Querying _metadata { categories } fails with:
# Cannot query field "categories" on type "IContentMetadata".
query GetContentWithCategories {
ArticlePage(limit: 10) {
items {
_metadata { displayName url { default } }
_itemMetadata { categories } # ["cms://taxonomy/categories/mortgages", ...]
}
}
}
# The value is an array of URI strings, never display names:
# cms://taxonomy/categories/<termKey>
# Resolve labels by joining against _TaxonomyTerm on the key.Filtering#
Categories accept the standard string filters, so one query covers single-term, multi-term and “any category at all” cases.
# Filter by one category
where: { _itemMetadata: { categories: { eq: "cms://taxonomy/categories/mortgages" } } }
# Filter by any of several (OR)
where: { _itemMetadata: { categories: { in: [
"cms://taxonomy/categories/mortgages",
"cms://taxonomy/categories/isas"
] } } }
# Only content that has been categorised at all
where: { _itemMetadata: { categories: { exist: true } } }
# Combine with a content type
where: {
_and: [
{ _metadata: { types: { eq: "ArticlePage" } } }
{ _itemMetadata: { categories: { eq: "cms://taxonomy/categories/mortgages" } } }
]
}
# A null variable is ignored, so ONE query serves the filtered and
# unfiltered cases - no need for two query constants.
query GetArticles($categories: [String]) {
ArticlePage(where: { _itemMetadata: { categories: { in: $categories } } }) { ... }
}Cross-type browse with facets#
This runs against _Content, not a single page type, so one filter returns articles, case studies and marketing pages together. Counts come from the unfiltered result so buckets stay visible after you narrow, and they are rolled up through the tree, so a parent shows everything beneath it. Pick a grouping row like Borrowing and you get every mortgage, loan and credit card page at once.
# Categories are facetable, so a browse UI gets its counts for free.
# Bucket names come back as term URIs, not display names.
query CategoryFacets {
_Content {
total
facets {
_itemMetadata {
categories(orderType: COUNT, orderBy: DESC, limit: 40) {
name # "cms://taxonomy/categories/mortgages"
count # 11
}
}
}
}
}
# Because this hangs off _Content rather than a single page type, the counts
# span EVERY content type at once. The old per-type ArticlePage.category
# property could never do this: each type had its own separate enum.26 items match
- 5 Savings Tips for 2025ArticlePage
Personal FinanceHow-to GuidesCampaign 2026Savings
- Arranged OverdraftsTraditionalPage
Personal Finance
- Article - Mortgage rates explainedArticlePage
Personal FinanceMarket InsightsRates and FeesEvergreenMortgages
- Article - Saving for your first homeArticlePage
How-to GuidesEvergreenFirst-time BuyersFirst-time Buyer MortgagesEasy Access
- Buy-to-Let MortgagesTraditionalPage
Personal FinanceBuy to Let
- Case Study - Family finance journeyCaseStudyPage
Customer StoriesEvergreenFamiliesFirst-time Buyer Mortgages
- Credit CardsTraditionalPage
Personal Finance
- Current AccountTraditionalPage
Personal Finance
Leaf-only tagging and query-time expansion#
Content here is tagged with its most specific term only. A page under /mortgage/remortgaging/ carries remortgaging, not also Mortgages, Borrowing and Product. That keeps the stored data honest and the chip row on an article short, but it means filtering by a parent has to expand to its descendants before it hits Graph. This is the reason parent being in the schema matters.
// Content is tagged leaf-only, so "show me everything under Mortgages"
// means expanding the term to its descendants before querying.
// src/lib/taxonomy.ts
export function descendantKeys(terms, key) {
const childrenOf = new Map();
for (const term of terms) {
if (!term.parent) continue;
childrenOf.set(term.parent, [...(childrenOf.get(term.parent) ?? []), term.key]);
}
const out = [];
const walk = (k, seen) => {
if (seen.has(k)) return;
seen.add(k);
out.push(k);
for (const child of childrenOf.get(k) ?? []) walk(child, seen);
};
walk(toTermKey(key), new Set());
return out;
}
// mortgages -> [mortgages, first_time_buyer_mortgage, remortgaging,
// buy_to_let, overpayments]
const uris = expandToUris(terms, ["mortgages"]);
// Then it is an ordinary `in` filter:
// where: { _itemMetadata: { categories: { in: $categories } } }
//
// On this instance that turns 3 exact matches into 11.Key Things to Know#
- →The alternative is denormalizing. You could tag every page with its whole ancestor chain so a plain
eqworks and counts roll up natively. That trades clean data and short chip rows for simpler queries. Either is defensible; know which one you picked. - →Grouping nodes never appear in facets. Nothing can be tagged with Borrowing, so Graph returns no bucket for it. The sidebar sums each term with its descendants locally, which is what lets it render a tree instead of a flat list of leaves.
- →Cash ISA is in the tree but not in the facets. It is a real, selectable term with no content behind it yet. Facets only return buckets that are in use, so the full taxonomy and the applied taxonomy are not the same list - worth knowing before you build navigation off facet output.
Public vs Internal#
Every term carries a usage flag, Public or Internal. It is documentation, not access control: Internal terms are indexed into Graph and returned by queries exactly like Public ones. Filtering them out of a public-facing navigation is the front end's job. Note that usage is absent from the taxonomy write schema, so API-created terms are Public until an editor changes them in Settings > Categories.
# Public terms only
query PublicTerms {
_TaxonomyTerm(where: { _metadata: { usage: { eq: "Public" } } }) {
items { _metadata { key displayName usage } }
}
}
# The Editorial Lifecycle branch in this demo (evergreen / needs_review /
# campaign_2026) is what you would mark Internal: useful for editors,
# meaningless to a visitor.Localization has no fallback#
This is the sharpest edge on the feature. Content falls back to the master language; taxonomy terms do not. Query an untranslated term in Dutch and you get an en dash, not the English name, so any multi-language UI has to implement its own fallback.
# Graph does NOT fall back to the master language for taxonomy terms.
#
# Master language -> the value stored when the term was created
# Other languages -> an en-dash placeholder "–" when no translation exists
#
# So a Dutch query for an untranslated term returns displayName: "–", not the
# English name. Treat that literal as "missing" and fall back yourself:
export const MISSING_TRANSLATION = "–";
export function termLabel(terms, keyOrUri) {
const key = toTermKey(keyOrUri);
const name = terms.find((t) => t.key === key)?.displayName;
if (!name || name === MISSING_TRANSLATION) return humanizeKey(key);
return name;
}Managing terms over REST#
Terms have their own experimental API, and assignment rides on the normal content API. The whole taxonomy in this demo is seeded by npm run seed:categories, which creates the tree parents-first and then tags every page by URL.
# Terms are managed through the experimental Taxonomy REST API.
# Requires an api:admin token AND CMS admin rights; feature-flagged per instance.
GET /v1/experimental/taxonomies/categories/terms?pageIndex=0&pageSize=100
POST /v1/experimental/taxonomies/categories/terms
GET /v1/experimental/taxonomies/categories/terms/{termKey}
PATCH /v1/experimental/taxonomies/categories/terms/{termKey}
DELETE /v1/experimental/taxonomies/categories/terms/{termKey}
# Create a term. Parents MUST exist first - a child pointing at an unknown
# parent is a 400. A term's parent can never be changed afterwards.
POST /v1/experimental/taxonomies/categories/terms
{
"key": "mortgages", # ^[A-Za-z][_0-9A-Za-z]+$ - NO HYPHENS
"displayName": "Mortgages",
"parent": "product",
"sortOrder": 30,
"isAvailable": true, # false = hidden from the UI and from Graph
"isSelectable": true # false = a grouping node nobody can tag with
}
# Assignment is NOT a taxonomy endpoint. `categories` is a built-in property
# on a content VERSION - you do not declare it on any content type:
PATCH /v1/content/{key}/versions/{version}
Content-Type: application/merge-patch+json
{ "properties": { "categories": { "value": [
"cms://taxonomy/categories/mortgages"
] } } }
# The CMS validates every URI and rejects, with the property named:
# "The taxonomy term 'x' could not be found in taxonomy 'categories'."
# "The taxonomy term 'x' is not selectable."
# "The taxonomy 'tags' is not supported."Key Things to Know#
- →Keys cannot contain hyphens. The pattern is
^[A-Za-z][_0-9A-Za-z]+$, so a slug likepersonal-financeis rejected. This demo uses snake_case throughout. - →Create parents before children, and choose the tree carefully: a term's parent cannot be changed later. Moving one means deleting and recreating it, which drops it from every item already tagged.
- →Only visible terms are indexed.
isAvailable: falsekeeps a term out of Graph entirely, which is how you retire one without deleting the history. - →Assignment is version-scoped. Categories belong to a content version, so they follow the normal draft-then-publish flow and a published version cannot be patched directly.
Source files5 files
export const TAXONOMY_KEY = "categories";
// Optimizely Graph does not fall back across languages for taxonomy terms. A
// term with no translation in the requested locale returns this en-dash
// placeholder rather than the master-language value, so treat it as "missing"
// and fall back yourself.
export const MISSING_TRANSLATION = "–";
export interface TaxonomyTermMeta {
key: string;
displayName: string;
description?: string | null;
usage?: string | null;
parent?: string | null;
}
export interface TermNode extends TaxonomyTermMeta {
children: TermNode[];
depth: number;
}
/** Builds the URI form stored on content: cms://taxonomy/categories/<termKey>. */
export function termUri(key: string): string {
return `cms://taxonomy/${TAXONOMY_KEY}/${key}`;
}
/** Inverse of termUri(). Returns null for anything that is not a category URI. */
export function keyFromTermUri(uri: string): string | null {
const prefix = `cms://taxonomy/${TAXONOMY_KEY}/`;
return uri.startsWith(prefix) ? uri.slice(prefix.length) : null;
}
/** Accepts either a bare term key or a full category URI and returns the key. */
export function toTermKey(value: string): string {
return keyFromTermUri(value) ?? value;
}
/**
* Human label for a term. Falls back to the key when the term is unknown or
* has no translation in the queried locale, so a label is never blank.
*/
export function termLabel(
terms: TaxonomyTermMeta[],
keyOrUri: string
): string {
const key = toTermKey(keyOrUri);
const term = terms.find((t) => t.key === key);
const name = term?.displayName;
if (!name || name === MISSING_TRANSLATION) return humanizeKey(key);
return name;
}
/** Turns a snake_case term key into a readable label, for use as a last resort. */
export function humanizeKey(key: string): string {
return key
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
/**
* Folds the flat term list Graph returns into a tree using the `parent` field.
* Terms whose parent is missing from the list are treated as roots so nothing
* is silently dropped.
*/
export function buildTermTree(terms: TaxonomyTermMeta[]): TermNode[] {
const byKey = new Map<string, TermNode>();
for (const term of terms) {
byKey.set(term.key, { ...term, children: [], depth: 0 });
}
const roots: TermNode[] = [];
for (const node of byKey.values()) {
const parent = node.parent ? byKey.get(node.parent) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
const setDepth = (nodes: TermNode[], depth: number): void => {
for (const node of nodes) {
node.depth = depth;
node.children.sort((a, b) => a.displayName.localeCompare(b.displayName));
setDepth(node.children, depth + 1);
}
};
roots.sort((a, b) => a.displayName.localeCompare(b.displayName));
setDepth(roots, 0);
return roots;
}
/** Flattens a term tree back into depth-ordered rows, for rendering an indented list. */
export function flattenTree(nodes: TermNode[]): TermNode[] {
return nodes.flatMap((node) => [node, ...flattenTree(node.children)]);
}
/**
* A term's key plus every key beneath it.
*
* Content is tagged leaf-only (a page under /mortgage/remortgaging/ carries
* `remortgaging`, not also `mortgages`), so filtering by a parent has to expand
* to its descendants. This is what makes the `parent` field earn its place, and
* it keeps the stored data clean instead of denormalizing ancestors onto every
* item.
*/
export function descendantKeys(terms: TaxonomyTermMeta[], key: string): string[] {
const childrenOf = new Map<string, string[]>();
for (const term of terms) {
if (!term.parent) continue;
const siblings = childrenOf.get(term.parent) ?? [];
siblings.push(term.key);
childrenOf.set(term.parent, siblings);
}
const out: string[] = [];
const walk = (k: string, seen: Set<string>): void => {
if (seen.has(k)) return;
seen.add(k);
out.push(k);
for (const child of childrenOf.get(k) ?? []) walk(child, seen);
};
walk(toTermKey(key), new Set());
return out;
}
/**
* Expands a set of selected term keys (or URIs) into the full URI list to pass
* to Graph as an `in` filter, so selecting "Mortgages" matches everything
* beneath it.
*/
export function expandToUris(
terms: TaxonomyTermMeta[],
keysOrUris: string[]
): string[] {
const expanded = new Set<string>();
for (const value of keysOrUris) {
for (const key of descendantKeys(terms, value)) expanded.add(key);
}
return [...expanded].map(termUri);
}
export interface FacetBucket {
name: string;
count: number;
}
export interface RolledUpTerm extends TermNode {
/** Items tagged with this exact term. */
ownCount: number;
/** Items tagged with this term or anything beneath it. */
totalCount: number;
}
/**
* Sums each term's own facet bucket with all of its descendants', so a parent
* row can show a meaningful count.
*
* Graph returns one bucket per term actually in use, which with leaf-only
* tagging means grouping nodes never appear. Rolling up locally is what lets a
* sidebar render the tree rather than a flat list of leaves.
*/
export function rollUpCounts(
terms: TaxonomyTermMeta[],
buckets: FacetBucket[]
): RolledUpTerm[] {
const own = new Map<string, number>();
for (const bucket of buckets) {
own.set(toTermKey(bucket.name), bucket.count);
}
return flattenTree(buildTermTree(terms)).map((node) => ({
...node,
ownCount: own.get(node.key) ?? 0,
totalCount: descendantKeys(terms, node.key).reduce(
(sum, key) => sum + (own.get(key) ?? 0),
0
),
}));
}
/**
* Root terms whose whole branch is editorial metadata rather than something a
* visitor should see.
*
* The `usage` flag (Public / Internal) is the CMS-native way to express this,
* but it is absent from the taxonomy write schema, so terms created through the
* API are always Public until an editor flips them in Settings > Categories.
* Until that happens this list is what keeps workflow terms off public pages;
* once the branch is marked Internal the usage check below covers it anyway.
*/
export const INTERNAL_ROOT_KEYS = ["lifecycle"];
/** Walks up the parent chain and returns the key of the term's root ancestor. */
function rootKeyOf(terms: TaxonomyTermMeta[], key: string): string {
const byKey = new Map(terms.map((t) => [t.key, t]));
let current = byKey.get(key);
const seen = new Set<string>();
while (current?.parent && !seen.has(current.key)) {
seen.add(current.key);
const parent = byKey.get(current.parent);
if (!parent) break;
current = parent;
}
return current?.key ?? key;
}
/** True when a term is safe to show to a site visitor. */
function isPublicTerm(terms: TaxonomyTermMeta[], keyOrUri: string): boolean {
const key = toTermKey(keyOrUri);
const term = terms.find((t) => t.key === key);
if (term?.usage === "Internal") return false;
return !INTERNAL_ROOT_KEYS.includes(rootKeyOf(terms, key));
}
/** Filters a list of category URIs down to the ones a visitor should see. */
export function publicCategoryUris(
terms: TaxonomyTermMeta[],
uris: string[]
): string[] {
return uris.filter((uri) => isPublicTerm(terms, uri));
}
/**
* Maps the legacy ArticlePage.category / CaseStudyPage.industry enum values onto
* taxonomy term keys. Content seeded before Categories existed still carries the
* old property, so the listing and article surfaces fall back through this map
* when a page has no categories assigned yet (and on instances where the
* taxonomy has not been seeded at all).
*/
export const LEGACY_CATEGORY_MAP: Record<string, string> = {
"personal-finance": "personal_finance",
"business-banking": "business_banking",
investments: "investments",
"market-insights": "market_insights",
};
/**
* The category URIs for a content item, preferring real taxonomy assignments and
* falling back to the legacy enum property when none exist.
*/
export function resolveCategoryUris(
categories: string[] | null | undefined,
legacyValue?: string | null
): string[] {
if (categories?.length) return categories;
const mapped = legacyValue ? LEGACY_CATEGORY_MAP[legacyValue] : undefined;
return mapped ? [termUri(mapped)] : [];
}
import { cacheTag } from "next/cache";
import { CACHE_TAGS, cachePublishedContent, cachedQueryFailed } from "@/lib/optimizely/cacheProfile";
import { graphClient } from "@/lib/optimizely/graphClient";
import type { TaxonomyTermMeta } from "@/lib/taxonomy";
export const GET_TAXONOMY_TERMS_QUERY = /* GraphQL */ `
query GetTaxonomyTerms($locale: [Locales]) {
_TaxonomyTerm(limit: 100, locale: $locale) {
total
items {
_metadata {
key
taxonomy
displayName
description
usage
parent
}
}
}
}
`;
export interface TaxonomyTermsResult {
terms: TaxonomyTermMeta[];
total: number;
fromCms: boolean;
}
interface GraphResponse {
_TaxonomyTerm?: {
total?: number | null;
items?: Array<{
_metadata?: {
key?: string | null;
taxonomy?: string | null;
displayName?: string | null;
description?: string | null;
usage?: string | null;
parent?: string | null;
} | null;
}> | null;
} | null;
}
const EMPTY: TaxonomyTermsResult = { terms: [], total: 0, fromCms: false };
// The SDK's generated page query does not select _itemMetadata, so a page that
// wants to render its own category chips fetches them by key. Both root fields
// travel in one request so this costs a single round trip.
export const GET_CONTENT_TAXONOMY_QUERY = /* GraphQL */ `
query GetContentTaxonomy($key: String!, $locale: [Locales]) {
_Content(where: { _metadata: { key: { eq: $key } } }, limit: 1, locale: $locale) {
items {
_itemMetadata { categories }
}
}
_TaxonomyTerm(limit: 100, locale: $locale) {
items {
_metadata { key displayName usage parent }
}
}
}
`;
export interface ContentTaxonomyResult {
/** Category term URIs assigned to the content item. */
uris: string[];
terms: TaxonomyTermMeta[];
}
const EMPTY_CONTENT_TAXONOMY: ContentTaxonomyResult = { uris: [], terms: [] };
// Both queries cache at the function, not the fetch: the SDK's request() does
// not forward next: { revalidate, tags }. Args form the cache key, so they must
// stay serializable.
async function fetchContentTaxonomy(
key: string,
locale: string
): Promise<{
_Content?: {
items?: Array<{ _itemMetadata?: { categories?: string[] | null } | null }> | null;
} | null;
_TaxonomyTerm?: GraphResponse["_TaxonomyTerm"];
}> {
"use cache";
cacheTag(CACHE_TAGS.page);
cachePublishedContent();
try {
return await graphClient().request(GET_CONTENT_TAXONOMY_QUERY, { key, locale: [locale] });
} catch (error) {
return cachedQueryFailed("fetchContentTaxonomy", error);
}
}
async function fetchTaxonomyTerms(locale: string): Promise<GraphResponse> {
"use cache";
cacheTag(CACHE_TAGS.page);
cachePublishedContent();
try {
return await graphClient().request(GET_TAXONOMY_TERMS_QUERY, { locale: [locale] });
} catch (error) {
return cachedQueryFailed("fetchTaxonomyTerms", error);
}
}
export async function getContentTaxonomy(
key: string | null | undefined,
options?: { locale?: string }
): Promise<ContentTaxonomyResult> {
if (!key) return EMPTY_CONTENT_TAXONOMY;
const { locale = "en" } = options ?? {};
try {
const res = await fetchContentTaxonomy(key, locale);
const uris = res?._Content?.items?.[0]?._itemMetadata?.categories ?? [];
const byKey = new Map<string, TaxonomyTermMeta>();
for (const item of res?._TaxonomyTerm?.items ?? []) {
const m = item?._metadata;
if (!m?.key || byKey.has(m.key)) continue;
byKey.set(m.key, {
key: m.key,
displayName: m.displayName ?? m.key,
usage: m.usage,
parent: m.parent,
});
}
return { uris, terms: [...byKey.values()] };
} catch {
return EMPTY_CONTENT_TAXONOMY;
}
}
export async function getTaxonomyTerms(options?: {
locale?: string;
}): Promise<TaxonomyTermsResult> {
const { locale = "en" } = options ?? {};
try {
const res = await fetchTaxonomyTerms(locale);
const items = res?._TaxonomyTerm?.items ?? [];
// Graph stores one term document per locale, so the same key can come back
// more than once even with a locale filter. Keep the first of each key.
const byKey = new Map<string, TaxonomyTermMeta>();
for (const item of items) {
const m = item?._metadata;
if (!m?.key || byKey.has(m.key)) continue;
byKey.set(m.key, {
key: m.key,
displayName: m.displayName ?? m.key,
description: m.description,
usage: m.usage,
parent: m.parent,
});
}
const terms = [...byKey.values()];
return { terms, total: terms.length, fromCms: terms.length > 0 };
} catch {
return EMPTY;
}
}
import { API_BASE, getManagementToken, apiFetch } from "./_shared";
// The CMS ships exactly one taxonomy today. Any other key is rejected by the API
// with "The taxonomy '<key>' is not supported."
export const TAXONOMY_KEY = "categories";
// Taxonomy endpoints sit behind /experimental/ and a per-instance feature flag.
// They are outside the v1 backward-compatibility guarantee, so every call the
// project makes goes through this module and nowhere else.
export const TAXONOMY_ENDPOINT = `${API_BASE}/v1/experimental/taxonomies/${TAXONOMY_KEY}/terms`;
// Term keys are far stricter than content keys: letters, digits and underscores
// only, starting with a letter. Hyphenated slugs like "personal-finance" are
// rejected, which is why the tree uses snake_case throughout.
export const TERM_KEY_PATTERN = /^[A-Za-z][_0-9A-Za-z]+$/;
export interface TaxonomyTerm {
key: string;
displayName: string;
description?: string;
sortOrder?: number;
parent?: string | null;
isAvailable?: boolean;
isSelectable?: boolean;
created?: string;
createdBy?: string;
lastModified?: string;
lastModifiedBy?: string;
}
/** Builds the URI form the content API expects: cms://taxonomy/categories/<termKey>. */
export function termUri(key: string): string {
return `cms://taxonomy/${TAXONOMY_KEY}/${key}`;
}
/** Inverse of termUri(). Returns null for anything that is not a category URI. */
export function keyFromTermUri(uri: string): string | null {
const prefix = `cms://taxonomy/${TAXONOMY_KEY}/`;
return uri.startsWith(prefix) ? uri.slice(prefix.length) : null;
}
async function authHeaders(): Promise<Record<string, string>> {
return {
Authorization: `Bearer ${await getManagementToken()}`,
"Content-Type": "application/json",
};
}
/**
* Lists one level of the taxonomy: the direct children of `parent`, or the
* root-level terms when `parent` is omitted. Follows paging until exhausted.
*
* Note the API's shape here - omitting `parent` returns ONLY roots, not the
* whole taxonomy. Use listAllTerms() when you want every term.
*/
export async function listTerms(parent?: string): Promise<TaxonomyTerm[]> {
const all: TaxonomyTerm[] = [];
const pageSize = 100;
for (let pageIndex = 0; ; pageIndex += 1) {
const query = new URLSearchParams({
pageIndex: String(pageIndex),
pageSize: String(pageSize),
});
if (parent) query.set("parent", parent);
const res = await apiFetch(`${TAXONOMY_ENDPOINT}?${query}`, {
headers: await authHeaders(),
});
if (!res.ok) {
throw new Error(`GET terms: ${res.status} ${(await res.text()).slice(0, 200)}`);
}
const data = (await res.json()) as { items?: TaxonomyTerm[]; totalCount?: number };
const items = data.items ?? [];
all.push(...items);
if (items.length < pageSize) return all;
}
}
/** Walks the whole taxonomy, level by level, and returns every term. */
export async function listAllTerms(): Promise<TaxonomyTerm[]> {
const all: TaxonomyTerm[] = [];
const seen = new Set<string>();
const walk = async (parent?: string): Promise<void> => {
for (const term of await listTerms(parent)) {
if (seen.has(term.key)) continue;
seen.add(term.key);
all.push(term);
await walk(term.key);
}
};
await walk();
return all;
}
/**
* Creates a term. A duplicate key returns 409, which is treated as "already
* there" so the seed stays a non-destructive upsert like the rest of the
* pipeline. Parents must exist before their children.
*/
export async function createTerm(
term: TaxonomyTerm
): Promise<"created" | "exists"> {
if (!TERM_KEY_PATTERN.test(term.key)) {
throw new Error(
`Invalid term key "${term.key}" - must match ${TERM_KEY_PATTERN} (letters, digits, underscores; no hyphens)`
);
}
const res = await apiFetch(TAXONOMY_ENDPOINT, {
method: "POST",
headers: await authHeaders(),
body: JSON.stringify(term),
});
if (res.status === 409) return "exists";
if (!res.ok) {
throw new Error(
`POST term ${term.key}: ${res.status} ${(await res.text()).slice(0, 300)}`
);
}
return "created";
}
/**
* Updates the mutable fields of a term. `parent` is deliberately absent: the
* CMS does not allow reparenting after creation, and the patch schema omits it.
*/
export async function patchTerm(
key: string,
patch: Pick<TaxonomyTerm, "displayName" | "description" | "sortOrder" | "isAvailable" | "isSelectable">
): Promise<void> {
const res = await apiFetch(`${TAXONOMY_ENDPOINT}/${key}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${await getManagementToken()}`,
"Content-Type": "application/merge-patch+json",
},
body: JSON.stringify(patch),
});
if (!res.ok) {
throw new Error(
`PATCH term ${key}: ${res.status} ${(await res.text()).slice(0, 300)}`
);
}
}
/** Deletes a term and its descendants. Content tagged with it loses that tag. */
export async function deleteTerm(key: string): Promise<void> {
const res = await apiFetch(`${TAXONOMY_ENDPOINT}/${key}`, {
method: "DELETE",
headers: await authHeaders(),
});
if (!res.ok && res.status !== 404) {
throw new Error(
`DELETE term ${key}: ${res.status} ${(await res.text()).slice(0, 300)}`
);
}
}
import type { TaxonomyTerm } from "./_taxonomy";
// The Mosey Bank category tree: four levels deep
// (product > borrowing > mortgages > remortgaging), which matches Optimizely's
// "avoid hierarchies deeper than three or four levels" guidance.
//
// Grouping nodes are isAvailable (so they still index into Graph and can anchor
// a browse tree) but isSelectable: false, so an editor cannot tag a page with a
// bare "Product". Assigning one returns
// 400 "The taxonomy term '<key>' is not selectable."
//
// Keys are snake_case because the API rejects hyphens (^[A-Za-z][_0-9A-Za-z]+$),
// and must be globally unique - hence first_time_buyer_mortgage (a product)
// versus first_time_buyers (an audience).
//
// Terms are created parents-first: a child whose parent does not exist 400s. A
// term's parent can NEVER be changed afterwards, so reshaping this tree means
// `npm run seed:categories -- --fresh` (delete and recreate).
export interface TermDef extends TaxonomyTerm {
key: string;
displayName: string;
}
export const TAXONOMY_TREE: TermDef[] = [
// Axis 1 - who the content is for
{ key: "audience", displayName: "Audience", description: "Who the content is written for.", sortOrder: 10, isAvailable: true, isSelectable: false },
{ key: "personal_finance", displayName: "Personal Finance", parent: "audience", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "first_time_buyers", displayName: "First-time Buyers", parent: "personal_finance", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "families", displayName: "Families", parent: "personal_finance", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "retirement_planners", displayName: "Retirement Planners", parent: "personal_finance", sortOrder: 30, isAvailable: true, isSelectable: true },
{ key: "business_banking", displayName: "Business Banking", parent: "audience", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "startups", displayName: "Startups", parent: "business_banking", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "established_sme", displayName: "Established SMEs", parent: "business_banking", sortOrder: 20, isAvailable: true, isSelectable: true },
// Axis 2 - which product the content concerns
{ key: "product", displayName: "Product", description: "The banking product the content concerns.", sortOrder: 20, isAvailable: true, isSelectable: false },
{ key: "everyday", displayName: "Everyday Banking", parent: "product", sortOrder: 10, isAvailable: true, isSelectable: false },
{ key: "current_accounts", displayName: "Current Accounts", parent: "everyday", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "instant_payments", displayName: "Instant Payments", parent: "current_accounts", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "mobile_banking", displayName: "Mobile Banking", parent: "current_accounts", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "foreign_exchange", displayName: "Foreign Exchange", parent: "everyday", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "travel_money", displayName: "Travel Money", parent: "foreign_exchange", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "international_payments", displayName: "International Payments", parent: "foreign_exchange", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "borrowing", displayName: "Borrowing", parent: "product", sortOrder: 20, isAvailable: true, isSelectable: false },
{ key: "mortgages", displayName: "Mortgages", parent: "borrowing", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "first_time_buyer_mortgage", displayName: "First-time Buyer Mortgages", parent: "mortgages", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "remortgaging", displayName: "Remortgaging", parent: "mortgages", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "buy_to_let", displayName: "Buy to Let", parent: "mortgages", sortOrder: 30, isAvailable: true, isSelectable: true },
{ key: "overpayments", displayName: "Overpayments", parent: "mortgages", sortOrder: 40, isAvailable: true, isSelectable: true },
{ key: "loans", displayName: "Loans and Overdrafts", parent: "borrowing", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "personal_loans", displayName: "Personal Loans", parent: "loans", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "overdrafts", displayName: "Overdrafts", parent: "loans", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "business_lending", displayName: "Business Lending", parent: "loans", sortOrder: 30, isAvailable: true, isSelectable: true },
{ key: "credit_cards", displayName: "Credit Cards", parent: "borrowing", sortOrder: 30, isAvailable: true, isSelectable: true },
{ key: "personal_credit_cards", displayName: "Personal Credit Cards", parent: "credit_cards", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "business_credit_cards", displayName: "Business Credit Cards", parent: "credit_cards", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "saving_investing", displayName: "Saving and Investing", parent: "product", sortOrder: 30, isAvailable: true, isSelectable: false },
{ key: "savings", displayName: "Savings", parent: "saving_investing", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "easy_access", displayName: "Easy Access", parent: "savings", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "fixed_rate", displayName: "Fixed Rate", parent: "savings", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "isas", displayName: "ISAs", parent: "saving_investing", sortOrder: 20, isAvailable: true, isSelectable: true },
// Deliberately left with no content: facets only return buckets that are in
// use, so this shows the difference between the full taxonomy and what is
// actually applied. See the /demo/categories page.
{ key: "cash_isa", displayName: "Cash ISA", parent: "isas", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "stocks_isa", displayName: "Stocks and Shares ISA", parent: "isas", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "junior_isa", displayName: "Junior ISA", parent: "isas", sortOrder: 30, isAvailable: true, isSelectable: true },
{ key: "investments", displayName: "Investments", parent: "saving_investing", sortOrder: 30, isAvailable: true, isSelectable: true },
{ key: "general_investment", displayName: "General Investment Account", parent: "investments", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "pensions", displayName: "Pensions", parent: "saving_investing", sortOrder: 40, isAvailable: true, isSelectable: true },
{ key: "business", displayName: "Business Products", parent: "product", sortOrder: 40, isAvailable: true, isSelectable: false },
{ key: "business_accounts", displayName: "Business Accounts", parent: "business", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "business_current_account", displayName: "Business Current Account", parent: "business_accounts", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "merchant_services", displayName: "Merchant Services", parent: "business_accounts", sortOrder: 20, isAvailable: true, isSelectable: true },
// Axis 3 - what kind of content it is. Two levels is right for this axis:
// the editorial angle does not subdivide the way products do.
{ key: "topic", displayName: "Topic", description: "The editorial angle of the content.", sortOrder: 30, isAvailable: true, isSelectable: false },
{ key: "market_insights", displayName: "Market Insights", parent: "topic", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "guides", displayName: "How-to Guides", parent: "topic", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "customer_stories", displayName: "Customer Stories", parent: "topic", sortOrder: 30, isAvailable: true, isSelectable: true },
{ key: "rates_and_fees", displayName: "Rates and Fees", parent: "topic", sortOrder: 40, isAvailable: true, isSelectable: true },
{ key: "support", displayName: "Help and Support", parent: "topic", sortOrder: 50, isAvailable: true, isSelectable: true },
{ key: "company", displayName: "About the Company", parent: "topic", sortOrder: 60, isAvailable: true, isSelectable: true },
// Axis 4 - editorial workflow. Mark this branch Internal in the CMS UI
// (Settings > Categories > Edit > Usage) to demo usage filtering: the write
// API has no usage field, so terms are created Public and toggled by hand.
// Applied to a handful of pages only - a lifecycle term on everything is
// noise that crowds out every other facet.
{ key: "lifecycle", displayName: "Editorial Lifecycle", description: "Internal editorial state. Not for public navigation.", sortOrder: 40, isAvailable: true, isSelectable: false },
{ key: "evergreen", displayName: "Evergreen", parent: "lifecycle", sortOrder: 10, isAvailable: true, isSelectable: true },
{ key: "needs_review", displayName: "Needs Review", parent: "lifecycle", sortOrder: 20, isAvailable: true, isSelectable: true },
{ key: "campaign_2026", displayName: "Campaign 2026", parent: "lifecycle", sortOrder: 30, isAvailable: true, isSelectable: true },
];
/**
* Maps the legacy ArticlePage.category / CaseStudyPage.industry enum values onto
* term keys. Used by the seed to migrate existing values and by the front end to
* fall back to the property when a page has no categories yet.
*/
export const LEGACY_CATEGORY_MAP: Record<string, string> = {
"personal-finance": "personal_finance",
"business-banking": "business_banking",
investments: "investments",
"market-insights": "market_insights",
};
/**
* Per-page term assignments, keyed by the trailing part of the page URL. These
* win outright over the rule table below and carry the editorial judgement a URL
* cannot express: which articles are guides versus market commentary, and which
* are flagged for review so the Internal branch has real data.
*/
const URL_OVERRIDES: Array<{ endsWith: string; terms: string[] }> = [
{ endsWith: "/insights/articles/mortgage-rates-explained/", terms: ["mortgages", "market_insights", "rates_and_fees", "personal_finance", "evergreen"] },
{ endsWith: "/insights/articles/saving-for-first-home/", terms: ["first_time_buyer_mortgage", "easy_access", "guides", "first_time_buyers", "evergreen"] },
{ endsWith: "/insights/articles/business-banking-essentials/", terms: ["business_accounts", "guides", "startups", "evergreen"] },
{ endsWith: "/articles-demo/business-banking-basics/", terms: ["business_accounts", "guides", "startups", "needs_review"] },
{ endsWith: "/articles-demo/guide-to-isas/", terms: ["isas", "guides", "personal_finance", "evergreen"] },
{ endsWith: "/articles-demo/savings-tips-2025/", terms: ["savings", "guides", "personal_finance", "campaign_2026"] },
{ endsWith: "/case-studies/local-bakery-growth/", terms: ["business_lending", "customer_stories", "startups", "evergreen"] },
{ endsWith: "/case-studies/family-finance-journey/", terms: ["first_time_buyer_mortgage", "customer_stories", "families", "evergreen"] },
];
/**
* Most-specific-wins URL rules for product placement. The FIRST pattern that
* matches supplies the product term, so deeper pages resolve to their own leaf
* rather than collapsing into the section term. Order matters: put the deepest
* paths first.
*
* Pages are tagged leaf-only - a page under /mortgage/remortgaging/ gets
* `remortgaging`, NOT also `mortgages` / `borrowing` / `product`. Filtering by a
* parent expands to its descendants at query time instead (see
* expandToUris in src/lib/taxonomy.ts).
*/
const PRODUCT_RULES: Array<{ match: RegExp; term: string }> = [
{ match: /\/personal\/current-account\/instant-payments/, term: "instant_payments" },
{ match: /\/personal\/current-account\/mobile-app/, term: "mobile_banking" },
{ match: /\/personal\/current-account\/travel-money/, term: "travel_money" },
{ match: /\/personal\/current-account/, term: "current_accounts" },
{ match: /\/business\/international-payments/, term: "international_payments" },
{ match: /\/mortgage\/first-time-buyers/, term: "first_time_buyer_mortgage" },
{ match: /\/mortgage\/remortgaging/, term: "remortgaging" },
{ match: /\/mortgage\/buy-to-let/, term: "buy_to_let" },
{ match: /\/mortgage\/overpayments/, term: "overpayments" },
{ match: /\/mortgage/, term: "mortgages" },
{ match: /\/personal\/overdrafts/, term: "overdrafts" },
{ match: /\/personal\/loans/, term: "personal_loans" },
{ match: /business-lending/, term: "business_lending" },
{ match: /\/business\/business-credit-cards/, term: "business_credit_cards" },
{ match: /\/personal\/credit-cards/, term: "personal_credit_cards" },
{ match: /easy-access-savings/, term: "easy_access" },
{ match: /fixed-rate-savings/, term: "fixed_rate" },
{ match: /\/personal\/savings/, term: "savings" },
{ match: /\/investments\/stocks-isa/, term: "stocks_isa" },
{ match: /\/investments\/junior-isa/, term: "junior_isa" },
{ match: /\/investments\/general-investment/, term: "general_investment" },
{ match: /\/investments\/pensions/, term: "pensions" },
{ match: /\/investments/, term: "investments" },
{ match: /business-current-account/, term: "business_current_account" },
{ match: /merchant-services/, term: "merchant_services" },
{ match: /\/business\/business-banking/, term: "business_accounts" },
];
/** Audience rules. Every matching rule contributes, so a page can serve two. */
const AUDIENCE_RULES: Array<{ match: RegExp; terms: string[] }> = [
{ match: /\/mortgage\/first-time-buyers/, terms: ["first_time_buyers"] },
{ match: /junior-isa/, terms: ["families"] },
{ match: /pensions/, terms: ["retirement_planners"] },
{ match: /merchant-services|business-lending/, terms: ["established_sme"] },
{ match: /\/business/, terms: ["business_banking"] },
{ match: /\/personal|\/mortgage|\/investments|\/insights/, terms: ["personal_finance"] },
];
/** Topic rules for non-editorial pages. */
const TOPIC_RULES: Array<{ match: RegExp; terms: string[] }> = [
{ match: /\/case-studies\//, terms: ["customer_stories"] },
{ match: /\/help\//, terms: ["support"] },
{ match: /\/about\//, terms: ["company"] },
{ match: /pricing/, terms: ["rates_and_fees"] },
];
/**
* Lifecycle is applied deliberately, to a handful of pages. Tagging everything
* `evergreen` (as an earlier version did) put one term on 97% of content and
* buried every other facet bucket.
*/
const LIFECYCLE_RULES: Array<{ match: RegExp; terms: string[] }> = [
{ match: /\/help\/accessibility/, terms: ["needs_review"] },
{ match: /\/about\/press/, terms: ["needs_review"] },
{ match: /fixed-rate-savings/, terms: ["campaign_2026"] },
{ match: /\/business\/pricing/, terms: ["campaign_2026"] },
{ match: /\/personal\/current-account\/$/, terms: ["evergreen"] },
{ match: /\/mortgage\/$/, terms: ["evergreen"] },
{ match: /\/personal\/savings\/$/, terms: ["evergreen"] },
];
// Hubs, fixtures and the site root carry no editorial meaning, so tagging them
// would only pollute the facet counts the demo is built to show.
const SKIP = [
/^\/$/,
/^\/demo-fixtures\/nav-flag/,
/^\/demo-fixtures\/$/,
/^\/demo-fixtures\/articles-demo\/$/,
/^\/contact-form\/$/,
/^\/(en\/)?insights\/$/,
/^\/(en\/)?insights\/articles\/$/,
/^\/(en\/)?insights\/case-studies\/$/,
];
/**
* Resolves the term keys for a page URL: an explicit override if one matches,
* otherwise the most specific product term plus every matching audience, topic
* and lifecycle term. Returns an empty array for pages that should stay
* untagged - the seed clears any categories those pages already carry.
*/
export function termsForUrl(url: string): string[] {
if (SKIP.some((re) => re.test(url))) return [];
const override = URL_OVERRIDES.find((o) => url.endsWith(o.endsWith));
if (override) return override.terms;
const terms = new Set<string>();
// Most specific product wins; only one product term per page.
const product = PRODUCT_RULES.find((r) => r.match.test(url));
if (product) terms.add(product.term);
for (const rule of AUDIENCE_RULES) {
if (rule.match.test(url)) rule.terms.forEach((t) => terms.add(t));
}
for (const rule of TOPIC_RULES) {
if (rule.match.test(url)) rule.terms.forEach((t) => terms.add(t));
}
for (const rule of LIFECYCLE_RULES) {
if (rule.match.test(url)) rule.terms.forEach((t) => terms.add(t));
}
return [...terms];
}
import {
CONTENT_ENDPOINT,
GRAPH_ENDPOINT,
SINGLE_KEY,
apiFetch,
getManagementToken,
patchPublishedPageProperties,
} from "./_shared";
import { createTerm, deleteTerm, listAllTerms, termUri } from "./_taxonomy";
import { TAXONOMY_TREE, termsForUrl } from "./taxonomy-tree";
const FRESH = process.argv.includes("--fresh") || process.env.SEED_FRESH === "1";
// Graph caps `limit` at 100, so walk the result set with a cursor.
const PAGES_QUERY = `query AllPages($cursor: String) {
_Page(
limit: 100
cursor: $cursor
locale: en
where: { _metadata: { url: { default: { exist: true } } } }
orderBy: { _metadata: { url: { default: ASC } } }
) {
total
cursor
items { _metadata { key displayName url { default } } }
}
}`;
interface PageRow {
key: string;
displayName: string;
url: string;
}
async function fetchPages(): Promise<PageRow[]> {
const pages: PageRow[] = [];
let cursor: string | undefined;
for (;;) {
const res = await apiFetch(GRAPH_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `epi-single ${SINGLE_KEY}`,
},
body: JSON.stringify({ query: PAGES_QUERY, variables: { cursor } }),
});
const body = (await res.json().catch(() => null)) as {
errors?: Array<{ message?: string }>;
data?: {
_Page?: {
cursor?: string | null;
items?: Array<{
_metadata?: { key?: string; displayName?: string; url?: { default?: string } };
}>;
};
};
} | null;
if (!res.ok || body?.errors?.length) {
const detail = body?.errors?.map((e) => e.message).join("; ") ?? String(res.status);
throw new Error(`Graph page query: ${detail}`);
}
const page = body?.data?._Page;
const items = page?.items ?? [];
pages.push(
...items
.map((i) => ({
key: i._metadata?.key ?? "",
displayName: i._metadata?.displayName ?? "(untitled)",
url: i._metadata?.url?.default ?? "",
}))
.filter((p) => p.key && p.url)
);
if (items.length < 100 || !page?.cursor) return pages;
cursor = page.cursor;
}
}
/**
* Orders the tree so every parent is created before its children. The API
* rejects a child whose parent does not exist yet, and a term's parent can
* never be changed afterwards.
*/
function parentsFirst(): typeof TAXONOMY_TREE {
const byKey = new Map(TAXONOMY_TREE.map((t) => [t.key, t]));
const depth = (key: string, seen = new Set<string>()): number => {
const term = byKey.get(key);
if (!term?.parent || seen.has(key)) return 0;
seen.add(key);
return 1 + depth(term.parent, seen);
};
return [...TAXONOMY_TREE].sort((a, b) => depth(a.key) - depth(b.key));
}
async function seedTerms(): Promise<void> {
if (FRESH) {
const existing = await listAllTerms();
// Deleting a parent cascades to its descendants, so delete deepest first to
// keep the log honest about what was removed.
for (const term of [...existing].reverse()) {
await deleteTerm(term.key).catch(() => undefined);
}
console.log(`[fresh] removed ${existing.length} existing term(s)`);
}
let created = 0;
let existed = 0;
for (const term of parentsFirst()) {
const result = await createTerm(term);
if (result === "created") created += 1;
else existed += 1;
}
console.log(
`[terms] ${created} created, ${existed} already present, ${TAXONOMY_TREE.length} total`
);
}
/**
* Reads the categories already on the live version, so a re-seed does not create
* a pointless new version for every page. Graph is not used here because it lags
* the CMS by ~60s and would make back-to-back runs churn versions.
*/
async function currentCategories(key: string, locale = "en"): Promise<string[]> {
const res = await apiFetch(`${CONTENT_ENDPOINT}/${key}/locales/${locale}?pageSize=1`, {
headers: { Authorization: `Bearer ${await getManagementToken()}` },
});
if (!res.ok) return [];
// Reads come back in PropertyData form ({ value: [...] }), the same shape
// wrapProps() produces on write - not as a bare array.
const data = (await res.json()) as {
items?: Array<{ properties?: { categories?: { value?: string[] | null } | null } }>;
};
return data.items?.[0]?.properties?.categories?.value ?? [];
}
function sameTerms(a: string[], b: string[]): boolean {
return a.length === b.length && [...a].sort().join() === [...b].sort().join();
}
async function assignCategories(): Promise<void> {
const pages = await fetchPages();
if (pages.length === 0) {
console.warn(
"[warn] Graph returned no pages - it may not have indexed this instance yet. Re-run in ~60s."
);
return;
}
let tagged = 0;
let unchanged = 0;
let skipped = 0;
let failed = 0;
let cleared = 0;
let stale = 0;
for (const page of pages) {
const terms = termsForUrl(page.url);
const uris = terms.map(termUri);
if (terms.length === 0) {
// A page the rules no longer tag may still carry categories from an
// earlier tree. Clear them rather than leaving stale URIs behind.
try {
if ((await currentCategories(page.key)).length === 0) {
skipped += 1;
continue;
}
await patchPublishedPageProperties(page.key, { categories: [] });
cleared += 1;
console.log(`[cleared] ${page.url}`);
} catch (err) {
failed += 1;
console.warn(`[warn] ${page.url}: ${(err as Error).message}`);
}
continue;
}
try {
if (sameTerms(await currentCategories(page.key), uris)) {
unchanged += 1;
continue;
}
// `categories` is a built-in property on page versions - it is not declared
// on any content type, and the CMS validates each URI against the taxonomy
// (term must exist, be available and be selectable).
//
// Every seeded page is published, and a published version cannot be
// patched, so this goes through the draft-then-publish helper rather than
// patchContentProperties.
await patchPublishedPageProperties(page.key, { categories: uris });
tagged += 1;
console.log(`[tagged] ${page.url} -> ${terms.join(", ")}`);
} catch (err) {
const message = (err as Error).message;
// Graph can serve a doc for content that has since been deleted.
if (message.includes("404")) {
stale += 1;
continue;
}
failed += 1;
console.warn(`[warn] ${page.url}: ${message}`);
}
}
console.log(
`[assign] ${tagged} tagged, ${unchanged} already correct, ${cleared} cleared, ${skipped} skipped by design, ${stale} stale in Graph, ${failed} failed`
);
}
async function main(): Promise<void> {
await seedTerms();
await assignCategories();
console.log(
"[done] Categories seeded. Graph needs ~30-60s to index terms and assignments."
);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});