From e37f3f0fa6891d1ec1e961a1f7a8bf8c9a07e814 Mon Sep 17 00:00:00 2001 From: Michal Nowak Date: Wed, 4 Feb 2026 22:08:23 +0100 Subject: [PATCH 1/4] feat: enhance article and category mapping with base path support --- .../src/modules/page/page.mapper.ts | 46 ++++++------- .../src/modules/page/page.service.ts | 13 +++- .../api-harmonization/article-list.mapper.ts | 8 ++- .../article-search.mapper.ts | 5 +- .../api-harmonization/article-search.model.ts | 3 + .../article-search.request.ts | 1 + .../article-search.service.ts | 2 +- .../src/frontend/ArticleSearch.client.tsx | 3 +- .../api-harmonization/category-list.mapper.ts | 7 +- .../src/api-harmonization/category.mapper.ts | 10 ++- .../cms/models/blocks/article-search.model.ts | 3 + .../blocks/cms.article-search.mapper.ts | 9 +++ .../articles/zendesk-article.mapper.ts | 64 +++---------------- .../articles/zendesk-article.service.ts | 35 ++++------ 14 files changed, 97 insertions(+), 112 deletions(-) diff --git a/apps/api-harmonization/src/modules/page/page.mapper.ts b/apps/api-harmonization/src/modules/page/page.mapper.ts index 1bb60b418..b1af4aaeb 100644 --- a/apps/api-harmonization/src/modules/page/page.mapper.ts +++ b/apps/api-harmonization/src/modules/page/page.mapper.ts @@ -44,6 +44,7 @@ export const mapArticle = ( article: Articles.Model.Article, category: Articles.Model.Category, mainLocale: string, + basePath = '/', ): Page => { return { meta: { @@ -77,7 +78,7 @@ export const mapArticle = ( }, }, hasOwnTitle: true, - breadcrumbs: mapArticleBreadcrumbs(article, category), + breadcrumbs: mapArticleBreadcrumbs(article, category, basePath), }, }; }; @@ -108,30 +109,25 @@ const mapPageBreadcrumbs = (page: CMS.Model.Page.Page): Breadcrumb[] => { return breadcrumbs.filter((breadcrumb) => breadcrumb.slug); }; -const mapArticleBreadcrumbs = (article: Articles.Model.Article, category: Articles.Model.Category): Breadcrumb[] => { - const breadcrumbs: Breadcrumb[] = []; - - function extractFromParent(parent: Articles.Model.Category['parent']): void { - if (!parent) return; - - if (parent.parent) { - extractFromParent(parent.parent); - } - - breadcrumbs.push({ - slug: parent.slug, - label: parent.title, - }); - } - - extractFromParent(category); - - breadcrumbs.push({ - slug: article.slug, - label: article.title, - }); - - return breadcrumbs.filter((breadcrumb) => breadcrumb.slug); +const mapArticleBreadcrumbs = ( + article: Articles.Model.Article, + category: Articles.Model.Category, + basePath = '/', +): Breadcrumb[] => { + // Build full URL paths for breadcrumbs + const categoryUrl = `${basePath}/${category.slug}`; + const articleUrl = `${categoryUrl}/${article.slug}`; + + return [ + { + slug: categoryUrl, + label: category.title, + }, + { + slug: articleUrl, + label: article.title, + }, + ]; }; export const mapInit = ( diff --git a/apps/api-harmonization/src/modules/page/page.service.ts b/apps/api-harmonization/src/modules/page/page.service.ts index 07a09405c..07bf80016 100644 --- a/apps/api-harmonization/src/modules/page/page.service.ts +++ b/apps/api-harmonization/src/modules/page/page.service.ts @@ -117,7 +117,7 @@ export class PageService { private processArticle = ( article: Articles.Model.Article, - _query: GetPageQuery, + query: GetPageQuery, headers: Models.Headers.AppHeaders, ) => { if (!article.category) { @@ -126,6 +126,15 @@ export class PageService { const category = this.articlesService.getCategory({ id: article.category.id, locale: headers['x-locale'] }); - return forkJoin([category]).pipe(map(([category]) => mapArticle(article, category, headers['x-locale']))); + return forkJoin([category]).pipe( + map(([category]) => { + // Extract base path from URL: /basePath/categorySlug/articleSlug -> /basePath + const slugParts = query.slug.split('/').filter(Boolean); + // Remove article slug (last segment) and category slug (second to last) + const basePath = slugParts.length > 2 ? '/' + slugParts.slice(0, -2).join('/') : '/'; + + return mapArticle(article, category, headers['x-locale'], basePath); + }), + ); }; } diff --git a/packages/blocks/article-list/src/api-harmonization/article-list.mapper.ts b/packages/blocks/article-list/src/api-harmonization/article-list.mapper.ts index 8e94367ed..7b12d5bf0 100644 --- a/packages/blocks/article-list/src/api-harmonization/article-list.mapper.ts +++ b/packages/blocks/article-list/src/api-harmonization/article-list.mapper.ts @@ -10,6 +10,8 @@ export const mapArticleList = ( category: Articles.Model.Category | undefined, locale: string, ): ArticleListBlock => { + const basePath = cms.parent?.slug ?? ''; + return { __typename: 'ArticleListBlock', id: cms.id, @@ -17,13 +19,13 @@ export const mapArticleList = ( description: cms.description, categoryLink: category ? { - url: category.slug, + url: `${basePath}/${category.slug}`, label: cms.labels.seeAllArticles, } : undefined, items: { ...articles, - data: articles.data.map((article) => mapArticle(article, cms, locale)), + data: articles.data.map((article) => mapArticle(article, cms, locale, basePath)), }, meta: cms.meta, }; @@ -33,9 +35,11 @@ const mapArticle = ( article: Omit, cms: CMS.Model.ArticleListBlock.ArticleListBlock, locale: string, + basePath: string, ) => { return { ...article, + slug: `${basePath}/${article.slug}`, createdAt: Utils.Date.formatDateRelative(article.createdAt, locale, cms.labels.today, cms.labels.yesterday), updatedAt: Utils.Date.formatDateRelative(article.updatedAt, locale, cms.labels.today, cms.labels.yesterday), }; diff --git a/packages/blocks/article-search/src/api-harmonization/article-search.mapper.ts b/packages/blocks/article-search/src/api-harmonization/article-search.mapper.ts index c75492b01..3499eef21 100644 --- a/packages/blocks/article-search/src/api-harmonization/article-search.mapper.ts +++ b/packages/blocks/article-search/src/api-harmonization/article-search.mapper.ts @@ -12,15 +12,16 @@ export const mapArticleSearch = ( title: cms.title, inputLabel: cms.inputLabel, category: cms.category, + parent: cms.parent, noResults: cms.noResults, }; }; -export const mapArticles = (articles: Articles.Model.Articles): ArticleList => { +export const mapArticles = (articles: Articles.Model.Articles, basePath?: string): ArticleList => { return { articles: articles.data.map((article) => ({ label: article.title, - url: article.slug, + url: basePath ? `${basePath}/${article.slug}` : article.slug, })), }; }; diff --git a/packages/blocks/article-search/src/api-harmonization/article-search.model.ts b/packages/blocks/article-search/src/api-harmonization/article-search.model.ts index 3a5fc58b1..1a6ecb95d 100644 --- a/packages/blocks/article-search/src/api-harmonization/article-search.model.ts +++ b/packages/blocks/article-search/src/api-harmonization/article-search.model.ts @@ -5,6 +5,9 @@ export class ArticleSearchBlock extends ApiModels.Block.Block { title?: string; inputLabel!: string; category?: string; + parent?: { + slug: string; + }; noResults!: { title: string; description?: string; diff --git a/packages/blocks/article-search/src/api-harmonization/article-search.request.ts b/packages/blocks/article-search/src/api-harmonization/article-search.request.ts index a66870f73..2caf9f2ff 100644 --- a/packages/blocks/article-search/src/api-harmonization/article-search.request.ts +++ b/packages/blocks/article-search/src/api-harmonization/article-search.request.ts @@ -9,4 +9,5 @@ export class SearchArticlesQuery implements Omit { return this.articlesService .searchArticles({ ...query, locale: headers['x-locale'] }) - .pipe(map((articles) => mapArticles(articles))); + .pipe(map((articles) => mapArticles(articles, query.basePath))); } } diff --git a/packages/blocks/article-search/src/frontend/ArticleSearch.client.tsx b/packages/blocks/article-search/src/frontend/ArticleSearch.client.tsx index 26e28c88b..6c32c20e1 100644 --- a/packages/blocks/article-search/src/frontend/ArticleSearch.client.tsx +++ b/packages/blocks/article-search/src/frontend/ArticleSearch.client.tsx @@ -23,6 +23,7 @@ export const ArticleSearchPure: React.FC = ({ inputLabel, noResults, category, + parent, accessToken, locale, routing, @@ -38,7 +39,7 @@ export const ArticleSearchPure: React.FC = ({ startTransition(async () => { try { const result = await sdk.blocks.searchArticles( - { query: value, limit: 5, offset: 0, category }, + { query: value, limit: 5, offset: 0, category, basePath: parent?.slug }, { 'x-locale': locale }, accessToken, ); diff --git a/packages/blocks/category-list/src/api-harmonization/category-list.mapper.ts b/packages/blocks/category-list/src/api-harmonization/category-list.mapper.ts index 679006095..719627885 100644 --- a/packages/blocks/category-list/src/api-harmonization/category-list.mapper.ts +++ b/packages/blocks/category-list/src/api-harmonization/category-list.mapper.ts @@ -7,12 +7,17 @@ export const mapCategoryList = ( categories: Articles.Model.Category[], _locale: string, ): CategoryListBlock => { + const basePath = cms.parent?.slug ?? ''; + return { __typename: 'CategoryListBlock', id: cms.id, title: cms.title, description: cms.description, - items: categories, + items: categories.map((category) => ({ + ...category, + slug: `${basePath}/${category.slug}`, + })), meta: cms.meta, }; }; diff --git a/packages/blocks/category/src/api-harmonization/category.mapper.ts b/packages/blocks/category/src/api-harmonization/category.mapper.ts index f3feb4a9a..2526c99e1 100644 --- a/packages/blocks/category/src/api-harmonization/category.mapper.ts +++ b/packages/blocks/category/src/api-harmonization/category.mapper.ts @@ -10,6 +10,8 @@ export const mapCategory = ( articles: Articles.Model.Articles, _locale: string, ): CategoryBlock => { + const basePath = cms.parent?.slug ?? ''; + return { __typename: 'CategoryBlock', id: cms.id, @@ -24,7 +26,7 @@ export const mapCategory = ( description: cms.description, items: { ...articles, - data: articles.data.map((article) => mapArticle(article, cms, _locale)), + data: articles.data.map((article) => mapArticle(article, cms, _locale, basePath)), }, }, }; @@ -35,10 +37,12 @@ export const mapCategoryArticles = ( articles: Articles.Model.Articles, _locale: string, ): CategoryArticles => { + const basePath = cms.parent?.slug ?? ''; + return { items: { ...articles, - data: articles.data.map((article) => mapArticle(article, cms, _locale)), + data: articles.data.map((article) => mapArticle(article, cms, _locale, basePath)), }, }; }; @@ -47,9 +51,11 @@ const mapArticle = ( article: Omit, cms: CMS.Model.CategoryBlock.CategoryBlock, _locale: string, + basePath: string, ) => { return { ...article, + slug: `${basePath}/${article.slug}`, createdAt: Utils.Date.formatDateRelative(article.createdAt, _locale, cms.labels.today, cms.labels.yesterday), updatedAt: Utils.Date.formatDateRelative(article.updatedAt, _locale, cms.labels.today, cms.labels.yesterday), }; diff --git a/packages/framework/src/modules/cms/models/blocks/article-search.model.ts b/packages/framework/src/modules/cms/models/blocks/article-search.model.ts index 42cadbc9e..9284949bc 100644 --- a/packages/framework/src/modules/cms/models/blocks/article-search.model.ts +++ b/packages/framework/src/modules/cms/models/blocks/article-search.model.ts @@ -4,6 +4,9 @@ export class ArticleSearchBlock extends Block.Block { title?: string; inputLabel!: string; category?: string; + parent?: { + slug: string; + }; noResults!: { title: string; description?: string; diff --git a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.article-search.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.article-search.mapper.ts index c85198ca7..64ec58051 100644 --- a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.article-search.mapper.ts +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.article-search.mapper.ts @@ -4,6 +4,9 @@ const MOCK_ARTICLE_SEARCH_BLOCK_EN: CMS.Model.ArticleSearchBlock.ArticleSearchBl id: 'article-search-1', title: 'Search for topics', inputLabel: 'What are you searching for?', + parent: { + slug: '/help-and-support', + }, noResults: { title: 'No results found', description: 'No results found', @@ -14,6 +17,9 @@ const MOCK_ARTICLE_SEARCH_BLOCK_DE: CMS.Model.ArticleSearchBlock.ArticleSearchBl id: 'article-search-1', title: 'Entdecke Anleitungen', inputLabel: 'Was suchen Sie?', + parent: { + slug: '/hilfe-und-support', + }, noResults: { title: 'Keine Ergebnisse gefunden', description: 'Keine Ergebnisse gefunden', @@ -24,6 +30,9 @@ const MOCK_ARTICLE_SEARCH_BLOCK_PL: CMS.Model.ArticleSearchBlock.ArticleSearchBl id: 'article-search-1', title: 'Przeglądaj tematy', inputLabel: 'Czego szukasz?', + parent: { + slug: '/pomoc-i-wsparcie', + }, noResults: { title: 'Nie znaleziono wyników', description: 'Nie znaleziono wyników', diff --git a/packages/integrations/zendesk/src/modules/articles/zendesk-article.mapper.ts b/packages/integrations/zendesk/src/modules/articles/zendesk-article.mapper.ts index 9e3e61393..e2422b6bd 100644 --- a/packages/integrations/zendesk/src/modules/articles/zendesk-article.mapper.ts +++ b/packages/integrations/zendesk/src/modules/articles/zendesk-article.mapper.ts @@ -16,21 +16,6 @@ type ZendeskSection = SectionObject; type ZendeskUser = UserObject; type ZendeskAttachment = ArticleAttachmentObject; -/** - * Base path for help center (full slug prefix for category and article URLs) per locale. - * Must match CMS/navigation URLs so breadcrumbs and links stay locale-consistent. - */ -const HELP_AND_SUPPORT_BASE_PATH_BY_LOCALE: Record = { - en: '/help-and-support', - de: '/hilfe-und-support', - pl: '/pomoc-i-wsparcie', -}; - -function getHelpAndSupportBasePath(locale: string): string { - const normalized = locale.toLowerCase().split('-')[0] ?? 'en'; - return HELP_AND_SUPPORT_BASE_PATH_BY_LOCALE[normalized] ?? '/help-and-support'; -} - /** * Extract avatar URL from Zendesk user object * Handles both photo.content_url and remote_photo_url @@ -102,18 +87,6 @@ function extractCategorySlugFromUrl(htmlUrl?: string, id?: number): string { return id?.toString() || ''; } -/** - * Transform Zendesk article links in HTML content to internal O2S links. - * Matches: *.zendesk.com/hc/{locale}/articles/{id}-{slug} - * Returns: /{locale-basePath}/{id}-{slug} - */ -function transformArticleLinks(html: string, locale: string): string { - const basePath = getHelpAndSupportBasePath(locale); - - // Match any URL containing zendesk.com/hc/.../articles/{id-slug} - return html.replace(/[^"'\s<>]*zendesk\.com\/hc\/[^/]+\/articles\/(\d+[^"'\s<>]*)/gi, `${basePath}/$1`); -} - function extractLeadFromBody(body: string | undefined, maxLength = 300): string { if (!body) { return ''; @@ -131,56 +104,43 @@ function extractLeadFromBody(body: string | undefined, maxLength = 300): string /** * Parse HTML body into article sections * Creates text section for HTML body only (inline images are already embedded in HTML) - * Transforms Zendesk article links to internal O2S links */ function parseBodyIntoSections( body: string | undefined, articleId: number, createdAt: string, updatedAt: string, - locale: string, ): Articles.Model.ArticleSection[] { if (!body) { return []; } - const transformedBody = transformArticleLinks(body, locale); - return [ { id: `section-text-${articleId}`, __typename: 'ArticleSectionText', createdAt, updatedAt, - content: transformedBody, + content: body, }, ]; } export function mapArticle( article: ZendeskArticle, - locale: string, category?: ZendeskCategory | ZendeskSection, author?: ZendeskUser, attachments: ZendeskAttachment[] = [], ): Articles.Model.Article { + // Article slug is just the article segment (without category) + // Full URL will be built by the page mapper using category.slug + article.slug const articleSlug = extractSlugFromUrl(article.html_url, article.id); - // Build full slug with category if available (category.slug is already full path) - // Check if category is ZendeskCategory (has no category_id property) vs ZendeskSection (has category_id) - let fullSlug = articleSlug; - if (category && !('category_id' in category)) { - // category is ZendeskCategory (not ZendeskSection) - const categorySlug = mapCategory(category as ZendeskCategory, locale).slug; - fullSlug = `${categorySlug}/${articleSlug}`; - } - const sections = parseBodyIntoSections( article.body, article.id!, article.created_at || '', article.updated_at || '', - locale, ); const lead = extractLeadFromBody(article.body); @@ -208,7 +168,7 @@ export function mapArticle( return { id: article.id?.toString() || '', - slug: fullSlug, + slug: articleSlug, createdAt: article.created_at || '', updatedAt: article.updated_at || '', title: article.title || '', @@ -227,10 +187,8 @@ export function mapArticle( }; } -export function mapCategory(category: ZendeskCategory, locale: string): Articles.Model.Category { - const segment = extractCategorySlugFromUrl(category.html_url, category.id); - const basePath = getHelpAndSupportBasePath(locale); - const slug = `${basePath}/${segment}`; +export function mapCategory(category: ZendeskCategory): Articles.Model.Category { + const slug = extractCategorySlugFromUrl(category.html_url, category.id); return { id: category.id?.toString() || '', slug, @@ -241,9 +199,9 @@ export function mapCategory(category: ZendeskCategory, locale: string): Articles }; } -export function mapCategories(categories: ZendeskCategory[], total: number, locale: string): Articles.Model.Categories { +export function mapCategories(categories: ZendeskCategory[], total: number): Articles.Model.Categories { return { - data: categories.map((category) => mapCategory(category, locale)), + data: categories.map((category) => mapCategory(category)), total, }; } @@ -255,14 +213,13 @@ export function mapCategories(categories: ZendeskCategory[], total: number, loca export function mapSearchArticles( articles: ZendeskArticle[], total: number, - locale: string, categoriesArray: (ZendeskCategory | undefined)[] = [], ): Articles.Model.Articles { return { data: articles.map((article, index) => { const articleSlug = extractSlugFromUrl(article.html_url, article.id); const category = categoriesArray[index]; - const categorySlug = category ? mapCategory(category, locale).slug : undefined; + const categorySlug = category ? mapCategory(category).slug : undefined; const fullSlug = categorySlug ? `${categorySlug}/${articleSlug}` : articleSlug; const lead = extractLeadFromBody(article.body); @@ -287,7 +244,6 @@ export function mapSearchArticles( export function mapArticlesWithCategories( articles: ZendeskArticle[], total: number, - locale: string, attachmentsArray: ZendeskAttachment[][] = [], authorsArray: (ZendeskUser | undefined)[] = [], categoriesArray: (ZendeskCategory | undefined)[] = [], @@ -296,7 +252,7 @@ export function mapArticlesWithCategories( data: articles.map((article, index) => { const articleSlug = extractSlugFromUrl(article.html_url, article.id); const category = categoriesArray[index]; - const categorySlug = category ? mapCategory(category, locale).slug : undefined; + const categorySlug = category ? mapCategory(category).slug : undefined; const fullSlug = categorySlug ? `${categorySlug}/${articleSlug}` : articleSlug; const lead = extractLeadFromBody(article.body); diff --git a/packages/integrations/zendesk/src/modules/articles/zendesk-article.service.ts b/packages/integrations/zendesk/src/modules/articles/zendesk-article.service.ts index cb0495f01..2e2e428ae 100644 --- a/packages/integrations/zendesk/src/modules/articles/zendesk-article.service.ts +++ b/packages/integrations/zendesk/src/modules/articles/zendesk-article.service.ts @@ -125,9 +125,7 @@ export class ZendeskArticleService extends Articles.Service { ); return forkJoin([categoryOrSection$, author$, attachments$]).pipe( - map(([category, author, attachments]) => - mapArticle(article, options.locale, category, author, attachments), - ), + map(([category, author, attachments]) => mapArticle(article, category, author, attachments)), ); }), catchError((error) => { @@ -145,7 +143,7 @@ export class ZendeskArticleService extends Articles.Service { // If category filter is provided, resolve it to numeric ID (if it's a slug) and fetch category const categoryFilter$ = options.category - ? this.resolveCategoryId(options.category, zendeskLocale, options.locale).pipe( + ? this.resolveCategoryId(options.category, zendeskLocale).pipe( switchMap((categoryId) => { if (!categoryId) { return of({ categoryId: undefined, category: undefined }); @@ -171,7 +169,7 @@ export class ZendeskArticleService extends Articles.Service { const articles = response.articles || []; if (articles.length === 0) { - return of(mapArticlesWithCategories(articles, 0, options.locale, [], [], [])); + return of(mapArticlesWithCategories(articles, 0, [], [], [])); } // Fetch attachments, authors, and categories in parallel using forkJoin @@ -208,7 +206,6 @@ export class ZendeskArticleService extends Articles.Service { mapArticlesWithCategories( articles, response.count, - options.locale, attachmentsArray, authorsArray, categoriesArray, @@ -235,7 +232,7 @@ export class ZendeskArticleService extends Articles.Service { if (!category) { throw new NotFoundException(`Category not found: ${options.id}`); } - return mapCategory(category, options.locale); + return mapCategory(category); }), catchError((error) => { if (error?.status === 404 || error?.message?.includes('404')) { @@ -250,7 +247,7 @@ export class ZendeskArticleService extends Articles.Service { return this.fetchCategories(zendeskLocale).pipe( map((categories) => { const category = categories.find((cat) => { - const mapped = mapCategory(cat, options.locale); + const mapped = mapCategory(cat); return mapped.slug === options.id || mapped.id === options.id; }); @@ -258,7 +255,7 @@ export class ZendeskArticleService extends Articles.Service { throw new NotFoundException(`Category not found: ${options.id}`); } - return mapCategory(category, options.locale); + return mapCategory(category); }), catchError((error) => { if (error instanceof NotFoundException) { @@ -302,7 +299,7 @@ export class ZendeskArticleService extends Articles.Service { const categories = response.data?.categories || []; // Zendesk doesn't provide total count in the response, so we use categories.length // In a real scenario, you might need to make additional requests to get the total - return mapCategories(categories, categories.length, options.locale); + return mapCategories(categories, categories.length); }), catchError((error) => { return throwError(() => new Error(`Failed to fetch categories: ${error.message || error}`)); @@ -323,7 +320,7 @@ export class ZendeskArticleService extends Articles.Service { // Add category filter if provided (resolve to numeric ID) const categoryFilter$ = options.category - ? this.resolveCategoryId(options.category, zendeskLocale, options.locale).pipe( + ? this.resolveCategoryId(options.category, zendeskLocale).pipe( switchMap((categoryId) => { if (!categoryId) { return of({ categoryId: undefined, category: undefined }); @@ -371,7 +368,7 @@ export class ZendeskArticleService extends Articles.Service { const total = (response.data as unknown as { count?: number })?.count ?? articles.length; if (articles.length === 0) { - return of(mapSearchArticles(articles, 0, options.locale, [])); + return of(mapSearchArticles(articles, 0, [])); } // Fetch only categories for search results (no attachments/authors needed) @@ -390,9 +387,7 @@ export class ZendeskArticleService extends Articles.Service { ); return forkJoin(categories$).pipe( - map((categoriesArray) => - mapSearchArticles(articles, total, options.locale, categoriesArray), - ), + map((categoriesArray) => mapSearchArticles(articles, total, categoriesArray)), ); }), catchError((error) => { @@ -444,22 +439,18 @@ export class ZendeskArticleService extends Articles.Service { ); } - private resolveCategoryId( - categoryIdOrSlug: string, - zendeskLocale: string, - applicationLocale: string, - ): Observable { + private resolveCategoryId(categoryIdOrSlug: string, zendeskLocale: string): Observable { // If it's already a numeric ID, return it const numericId = Number(categoryIdOrSlug); if (!isNaN(numericId)) { return of(numericId); } - // Otherwise, it's a slug - fetch all categories and find by slug (using application locale for slug comparison) + // Otherwise, it's a slug - fetch all categories and find by slug return this.fetchCategories(zendeskLocale).pipe( map((categories) => { for (const category of categories) { - const mapped = mapCategory(category, applicationLocale); + const mapped = mapCategory(category); if (mapped.slug === categoryIdOrSlug || mapped.id === categoryIdOrSlug) { return category.id; } From 54707ac1e27d061a94e91abcd50b7754b6bb5297 Mon Sep 17 00:00:00 2001 From: Michal Nowak Date: Wed, 4 Feb 2026 22:11:52 +0100 Subject: [PATCH 2/4] refactor(zendesk): update article and category slug formats --- .../integrations/articles/zendesk/features.md | 81 +++++++++++-------- .../integrations/articles/zendesk/usage.md | 71 ++++++++-------- 2 files changed, 82 insertions(+), 70 deletions(-) diff --git a/apps/docs/docs/integrations/articles/zendesk/features.md b/apps/docs/docs/integrations/articles/zendesk/features.md index 23776e7d6..c81379f6d 100644 --- a/apps/docs/docs/integrations/articles/zendesk/features.md +++ b/apps/docs/docs/integrations/articles/zendesk/features.md @@ -28,13 +28,13 @@ The Zendesk Help Center integration provides: The following table shows which methods from the base ArticleService are currently supported by the Zendesk integration: -| Method | Description | Supported | -| --------------- | --------------------------------------------- | --------- | -| getArticle | Retrieve a single article by slug/ID | ✓ | -| getArticleList | Retrieve a list of articles with filtering | ✓ | -| getCategory | Retrieve a single category by ID or slug | ✓ | -| getCategoryList | Retrieve a list of categories | ✓ | -| searchArticles | Search articles with query and filters | ✓ | +| Method | Description | Supported | +| --------------- | ------------------------------------------ | --------- | +| getArticle | Retrieve a single article by slug/ID | ✓ | +| getArticleList | Retrieve a list of articles with filtering | ✓ | +| getCategory | Retrieve a single category by ID or slug | ✓ | +| getCategoryList | Retrieve a list of categories | ✓ | +| searchArticles | Search articles with query and filters | ✓ | ## Module Structure @@ -118,48 +118,58 @@ The integration maps Zendesk article data to the standard article model with the ### Field Mapping -| Zendesk Field | Normalized Field | Notes | -| ----------------- | ---------------- | ------------------------------------------ | -| id | id | Converted to string | -| created_at | createdAt | ISO date string | -| updated_at | updatedAt | ISO date string | -| title | title | Article title | -| body | sections | Parsed into ArticleSectionText | -| body (excerpt) | lead | First 300 characters of plain text | -| label_names | tags | Article labels/tags | -| html_url | slug | Extracted and combined with category slug | -| author_id | author | Fetched separately with avatar | -| section_id | category | Resolved via section → category lookup | +| Zendesk Field | Normalized Field | Notes | +| -------------- | ---------------- | -------------------------------------- | +| id | id | Converted to string | +| created_at | createdAt | ISO date string | +| updated_at | updatedAt | ISO date string | +| title | title | Article title | +| body | sections | Parsed into ArticleSectionText | +| body (excerpt) | lead | First 300 characters of plain text | +| label_names | tags | Article labels/tags | +| html_url | slug | Article segment extracted from URL | +| author_id | author | Fetched separately with avatar | +| section_id | category | Resolved via section → category lookup | ### Category Field Mapping -| Zendesk Field | Normalized Field | Notes | -| ------------- | ---------------- | --------------------------------- | -| id | id | Converted to string | -| created_at | createdAt | ISO date string | -| updated_at | updatedAt | ISO date string | -| name | title | Category name | -| description | description | Category description | -| html_url | slug | Full path with locale base | +| Zendesk Field | Normalized Field | Notes | +| ------------- | ---------------- | ------------------------------------ | +| id | id | Converted to string | +| created_at | createdAt | ISO date string | +| updated_at | updatedAt | ISO date string | +| name | title | Category name | +| description | description | Category description | +| html_url | slug | Category segment only (no base path) | ### Slug Generation -Article slugs are generated following this pattern: +The Zendesk integration returns article and category slugs as segments extracted from Zendesk URLs: + +**Article slug format:** ``` -/{locale-base}/{category-id}-{category-name}/{article-id}-{article-title} +{category-id}-{category-name}/{article-id}-{article-title} ``` -**Locale bases:** -- English: `/help-and-support` -- German: `/hilfe-und-support` -- Polish: `/pomoc-i-wsparcie` +**Category slug format:** -**Example:** ``` -/help-and-support/12345-Maintenance/67890-Tool-Care-Guide +{category-id}-{category-name} ``` +The base path (e.g., `/help-and-support`) is **not** included in the slug returned by the integration. Instead, it's configured in the CMS block configuration via `parent.slug` property. This allows for flexible URL structures without hardcoding locale-specific paths in the integration code. + +**Example slugs returned by the integration:** + +- Article: `12345-Maintenance/67890-Tool-Care-Guide` +- Category: `12345-Maintenance` + +**Full URL construction happens in:** + +- CMS blocks (ArticleList, CategoryList) using `cms.parent.slug` +- Page mapper for article detail pages using extracted base path from URL + ### Article Sections Article body content is converted into sections: @@ -196,6 +206,7 @@ Help Center ``` The integration: + 1. Fetches articles with their section IDs 2. Resolves section → category relationship 3. Builds proper slugs with category information diff --git a/apps/docs/docs/integrations/articles/zendesk/usage.md b/apps/docs/docs/integrations/articles/zendesk/usage.md index 79b23a7a3..6238ada29 100644 --- a/apps/docs/docs/integrations/articles/zendesk/usage.md +++ b/apps/docs/docs/integrations/articles/zendesk/usage.md @@ -18,14 +18,14 @@ Retrieve a list of articles with optional filtering and pagination. **Query Parameters:** -| Parameter | Type | Description | Required | -| --------- | ------ | --------------------------------------------------- | -------- | -| locale | string | Language code (en, de, pl) | Yes | -| category | string | Filter by category ID or slug | No | -| offset | number | Pagination offset | No | -| limit | number | Number of articles per page (default: 10) | No | -| sortBy | string | Sort field | No | -| sortOrder | string | Sort direction (asc, desc) | No | +| Parameter | Type | Description | Required | +| --------- | ------ | ----------------------------------------- | -------- | +| locale | string | Language code (en, de, pl) | Yes | +| category | string | Filter by category ID or slug | No | +| offset | number | Pagination offset | No | +| limit | number | Number of articles per page (default: 10) | No | +| sortBy | string | Sort field | No | +| sortOrder | string | Sort direction (asc, desc) | No | **Example Request:** @@ -41,7 +41,7 @@ GET /articles?locale=en&limit=10&offset=0 "data": [ { "id": "12345", - "slug": "/help-and-support/67890-Maintenance/12345-Tool-Care-Guide", + "slug": "67890-Maintenance/12345-Tool-Care-Guide", "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-01-16T14:20:00Z", "title": "Tool Care Guide", @@ -72,8 +72,8 @@ Retrieve a specific article by slug with full content. **Path Parameters:** -| Parameter | Type | Description | Required | -| --------- | ------ | ---------------------------------------------- | -------- | +| Parameter | Type | Description | Required | +| --------- | ------ | ------------------------------------------------ | -------- | | slug | string | Article slug or ID (e.g., "12345-article-title") | Yes | **Query Parameters:** @@ -93,7 +93,7 @@ GET /articles/12345-tool-care-guide?locale=en ```json { "id": "12345", - "slug": "/help-and-support/67890-Maintenance/12345-Tool-Care-Guide", + "slug": "67890-Maintenance/12345-Tool-Care-Guide", "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-01-16T14:20:00Z", "title": "Tool Care Guide", @@ -160,7 +160,7 @@ GET /articles/categories?locale=en "data": [ { "id": "67890", - "slug": "/help-and-support/67890-Maintenance", + "slug": "67890-Maintenance", "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-10T00:00:00Z", "title": "Maintenance", @@ -178,9 +178,9 @@ Retrieve a specific category by ID or slug. **Path Parameters:** -| Parameter | Type | Description | Required | -| --------- | ------ | --------------------- | -------- | -| id | string | Category ID or slug | Yes | +| Parameter | Type | Description | Required | +| --------- | ------ | ------------------- | -------- | +| id | string | Category ID or slug | Yes | **Query Parameters:** @@ -199,7 +199,7 @@ GET /articles/categories/67890?locale=en ```json { "id": "67890", - "slug": "/help-and-support/67890-Maintenance", + "slug": "67890-Maintenance", "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-10T00:00:00Z", "title": "Maintenance", @@ -215,15 +215,15 @@ Search articles with full-text query. **Body Parameters:** -| Parameter | Type | Description | Required | -| --------- | ------ | -------------------------------------- | -------- | -| locale | string | Language code (en, de, pl) | Yes | -| query | string | Search query | No | -| category | string | Filter by category ID | No | -| dateFrom | string | Filter articles created after (ISO) | No | -| dateTo | string | Filter articles created before (ISO) | No | -| sortBy | string | Sort field | No | -| sortOrder | string | Sort direction (asc, desc) | No | +| Parameter | Type | Description | Required | +| --------- | ------ | ------------------------------------ | -------- | +| locale | string | Language code (en, de, pl) | Yes | +| query | string | Search query | No | +| category | string | Filter by category ID | No | +| dateFrom | string | Filter articles created after (ISO) | No | +| dateTo | string | Filter articles created before (ISO) | No | +| sortBy | string | Sort field | No | +| sortOrder | string | Sort direction (asc, desc) | No | **Example Request:** @@ -246,7 +246,7 @@ Content-Type: application/json "data": [ { "id": "12345", - "slug": "/help-and-support/67890-Maintenance/12345-Tool-Care-Guide", + "slug": "67890-Maintenance/12345-Tool-Care-Guide", "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-01-16T14:20:00Z", "title": "Tool Care Guide", @@ -271,19 +271,20 @@ You should always use the short locale format (en, de, pl) in your API requests. ## Slug Format -Article slugs follow a hierarchical pattern that includes the category: +Article slugs returned by the Zendesk integration contain the category and article segments: ``` -/{locale-base}/{category-id}-{category-name}/{article-id}-{article-title} +{category-id}-{category-name}/{article-id}-{article-title} ``` -**Examples:** +**Example slugs returned by the integration:** -| Locale | Slug Example | -| ------ | ----------------------------------------------------------- | -| en | `/help-and-support/67890-Maintenance/12345-Tool-Care-Guide` | -| de | `/hilfe-und-support/67890-Wartung/12345-Werkzeugpflege` | -| pl | `/pomoc-i-wsparcie/67890-Konserwacja/12345-Pielegnacja` | +| Type | Slug Example | +| -------- | ----------------------------------------- | +| Article | `67890-Maintenance/12345-Tool-Care-Guide` | +| Category | `67890-Maintenance` | + +The base path (e.g., `/help-and-support`, `/hilfe-und-support`) is configured separately in CMS block configuration via `parent.slug` property, not hardcoded in the integration. This allows the same integration to work with different URL structures. ## Filtering Examples From 4b8d9058e478596bc664973cbd3cc1ea4283cffe Mon Sep 17 00:00:00 2001 From: Michal Nowak Date: Thu, 5 Feb 2026 10:37:58 +0100 Subject: [PATCH 3/4] refactor(mappers): normalize URL paths for article and category slugs to prevent double slashes --- apps/api-harmonization/src/modules/page/page.mapper.ts | 6 +++--- .../src/api-harmonization/article-list.mapper.ts | 4 ++-- .../src/api-harmonization/article-search.mapper.ts | 2 +- .../src/api-harmonization/category-list.mapper.ts | 2 +- .../category/src/api-harmonization/category.mapper.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/api-harmonization/src/modules/page/page.mapper.ts b/apps/api-harmonization/src/modules/page/page.mapper.ts index b1af4aaeb..7158a31ad 100644 --- a/apps/api-harmonization/src/modules/page/page.mapper.ts +++ b/apps/api-harmonization/src/modules/page/page.mapper.ts @@ -114,9 +114,9 @@ const mapArticleBreadcrumbs = ( category: Articles.Model.Category, basePath = '/', ): Breadcrumb[] => { - // Build full URL paths for breadcrumbs - const categoryUrl = `${basePath}/${category.slug}`; - const articleUrl = `${categoryUrl}/${article.slug}`; + // Build full URL paths for breadcrumbs (normalize to avoid double slashes) + const categoryUrl = `${basePath}/${category.slug}`.replace(/\/+/g, '/'); + const articleUrl = `${categoryUrl}/${article.slug}`.replace(/\/+/g, '/'); return [ { diff --git a/packages/blocks/article-list/src/api-harmonization/article-list.mapper.ts b/packages/blocks/article-list/src/api-harmonization/article-list.mapper.ts index 7b12d5bf0..88ac142e1 100644 --- a/packages/blocks/article-list/src/api-harmonization/article-list.mapper.ts +++ b/packages/blocks/article-list/src/api-harmonization/article-list.mapper.ts @@ -19,7 +19,7 @@ export const mapArticleList = ( description: cms.description, categoryLink: category ? { - url: `${basePath}/${category.slug}`, + url: `${basePath}/${category.slug}`.replace(/\/+/g, '/'), label: cms.labels.seeAllArticles, } : undefined, @@ -39,7 +39,7 @@ const mapArticle = ( ) => { return { ...article, - slug: `${basePath}/${article.slug}`, + slug: `${basePath}/${article.slug}`.replace(/\/+/g, '/'), createdAt: Utils.Date.formatDateRelative(article.createdAt, locale, cms.labels.today, cms.labels.yesterday), updatedAt: Utils.Date.formatDateRelative(article.updatedAt, locale, cms.labels.today, cms.labels.yesterday), }; diff --git a/packages/blocks/article-search/src/api-harmonization/article-search.mapper.ts b/packages/blocks/article-search/src/api-harmonization/article-search.mapper.ts index 3499eef21..811ec5fac 100644 --- a/packages/blocks/article-search/src/api-harmonization/article-search.mapper.ts +++ b/packages/blocks/article-search/src/api-harmonization/article-search.mapper.ts @@ -21,7 +21,7 @@ export const mapArticles = (articles: Articles.Model.Articles, basePath?: string return { articles: articles.data.map((article) => ({ label: article.title, - url: basePath ? `${basePath}/${article.slug}` : article.slug, + url: basePath ? `${basePath}/${article.slug}`.replace(/\/+/g, '/') : article.slug, })), }; }; diff --git a/packages/blocks/category-list/src/api-harmonization/category-list.mapper.ts b/packages/blocks/category-list/src/api-harmonization/category-list.mapper.ts index 719627885..3ea4f2662 100644 --- a/packages/blocks/category-list/src/api-harmonization/category-list.mapper.ts +++ b/packages/blocks/category-list/src/api-harmonization/category-list.mapper.ts @@ -16,7 +16,7 @@ export const mapCategoryList = ( description: cms.description, items: categories.map((category) => ({ ...category, - slug: `${basePath}/${category.slug}`, + slug: `${basePath}/${category.slug}`.replace(/\/+/g, '/'), })), meta: cms.meta, }; diff --git a/packages/blocks/category/src/api-harmonization/category.mapper.ts b/packages/blocks/category/src/api-harmonization/category.mapper.ts index 2526c99e1..5f94ec74c 100644 --- a/packages/blocks/category/src/api-harmonization/category.mapper.ts +++ b/packages/blocks/category/src/api-harmonization/category.mapper.ts @@ -55,7 +55,7 @@ const mapArticle = ( ) => { return { ...article, - slug: `${basePath}/${article.slug}`, + slug: `${basePath}/${article.slug}`.replace(/\/+/g, '/'), createdAt: Utils.Date.formatDateRelative(article.createdAt, _locale, cms.labels.today, cms.labels.yesterday), updatedAt: Utils.Date.formatDateRelative(article.updatedAt, _locale, cms.labels.today, cms.labels.yesterday), }; From f3dda289760af423fc22ff67e903bbf9440d6791 Mon Sep 17 00:00:00 2001 From: Michal Nowak Date: Thu, 5 Feb 2026 10:50:42 +0100 Subject: [PATCH 4/4] chore(changeset): add changeset for minor and major updates --- .changeset/smooth-mails-fall.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/smooth-mails-fall.md diff --git a/.changeset/smooth-mails-fall.md b/.changeset/smooth-mails-fall.md new file mode 100644 index 000000000..62efe5b5a --- /dev/null +++ b/.changeset/smooth-mails-fall.md @@ -0,0 +1,14 @@ +--- +'@o2s/blocks.article-search': minor +'@o2s/blocks.category-list': minor +'@o2s/configs.integrations': minor +'@o2s/integrations.zendesk': major +'@o2s/blocks.article-list': minor +'@o2s/integrations.mocked': minor +'@o2s/blocks.category': minor +'@o2s/api-harmonization': minor +'@o2s/framework': minor +'@o2s/docs': minor +--- + +feat(zendesk): remove hardcoded locale base paths from article slugs