Skip to content

Commit ddc3730

Browse files
authored
Fix bespoke landing page duplicate articles and flat sidebar (#59750)
1 parent 169b547 commit ddc3730

6 files changed

Lines changed: 120 additions & 8 deletions

File tree

src/article-api/lib/get-all-toc-items.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,24 @@ export function flattenTocItems(
101101
): LinkData[] {
102102
const { excludeParents = true } = options
103103
const result: LinkData[] = []
104+
const seen = new Set<string>()
104105

105106
function recurse(items: TocItem[]) {
106107
for (const item of items) {
107108
const hasChildren = item.childTocItems && item.childTocItems.length > 0
108109

109110
// Include this item if it's a leaf or if we're including parents
111+
// Deduplicate by href - needed when a page lists both individual
112+
// articles and their parent group as children (e.g., bespoke landing pages)
110113
if (!hasChildren || !excludeParents) {
111-
result.push({
112-
href: item.href,
113-
title: item.title,
114-
intro: item.intro,
115-
})
114+
if (!seen.has(item.href)) {
115+
seen.add(item.href)
116+
result.push({
117+
href: item.href,
118+
title: item.title,
119+
intro: item.intro,
120+
})
121+
}
116122
}
117123

118124
// Recurse into children

src/fixtures/fixtures/content/get-started/article-grid-bespoke/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ spotlight:
1919
- article: /grid-category-two/grid-article-three
2020
image: /assets/images/placeholder.png
2121
children:
22+
- /grid-category-one/grid-article-one
23+
- /grid-category-one/grid-article-two
2224
- /grid-category-one
2325
- /grid-category-two
2426
---

src/fixtures/tests/playwright-rendering.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,6 +1410,31 @@ test.describe('LandingArticleGridWithFilter component', () => {
14101410
const articleGrid = page.getByTestId('article-grid')
14111411
await expect(articleGrid).toBeVisible()
14121412
})
1413+
1414+
test('bespoke landing page does not show duplicate articles', async ({ page }) => {
1415+
// The bespoke fixture lists individual articles AND their parent group
1416+
// as children, which would cause duplicates without deduplication.
1417+
await page.goto('/get-started/article-grid-bespoke')
1418+
1419+
const articleGrid = page.getByTestId('article-grid')
1420+
await expect(articleGrid).toBeVisible()
1421+
1422+
const articleCards = articleGrid.getByTestId('article-card')
1423+
// There are 4 unique articles across grid-category-one (2) and grid-category-two (2).
1424+
// Even though grid-article-one and grid-article-two are listed both individually
1425+
// and as children of grid-category-one, they should appear only once each.
1426+
await expect(articleCards).toHaveCount(4)
1427+
1428+
// Verify no duplicate titles by collecting all card titles
1429+
const titles: string[] = []
1430+
const count = await articleCards.count()
1431+
for (let i = 0; i < count; i++) {
1432+
const title = await articleCards.nth(i).locator('h3 span').textContent()
1433+
titles.push(title!)
1434+
}
1435+
const uniqueTitles = new Set(titles)
1436+
expect(uniqueTitles.size).toBe(titles.length)
1437+
})
14131438
})
14141439

14151440
test.describe('Non-child page resolution', () => {

src/frame/middleware/context/current-product-tree.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,24 @@ function excludeHidden(tree: TitlesTree) {
142142
}
143143
if (tree.sidebarLink) newTree.sidebarLink = tree.sidebarLink
144144
if (tree.layout && typeof tree.layout === 'string') newTree.layout = tree.layout
145+
if (tree.crossProductChild) newTree.crossProductChild = true
145146
return newTree
146147
}
147148

148149
function sidebarTree(tree: TitlesTree) {
149150
const { href, title, shortTitle, childPages, sidebarLink } = tree
150151
// Filter out cross-product children from the sidebar
151152
const filteredChildPages = childPages.filter((child) => !child.crossProductChild)
152-
const childChildPages = filteredChildPages.map(sidebarTree)
153+
154+
// Filter out children that are descendants of another sibling.
155+
// When a page lists both a subdirectory and individual articles from it,
156+
// the articles should only appear nested under the subdirectory in the sidebar.
157+
const siblingHrefs = filteredChildPages.map((c) => c.href)
158+
const dedupedChildPages = filteredChildPages.filter(
159+
(child) => !siblingHrefs.some((sh) => sh !== child.href && child.href.startsWith(`${sh}/`)),
160+
)
161+
162+
const childChildPages = dedupedChildPages.map(sidebarTree)
153163
const newTree: TitlesTree = {
154164
href,
155165
title: shortTitle || title,

src/frame/tests/non-child-pages-resolution.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,65 @@ describe('Non-child page resolution', () => {
191191
expect(sidebarChildPages.map((c) => c.title)).toEqual(['Foo', 'Bar'])
192192
})
193193
})
194+
195+
describe('descendant-of-sibling filtering in sidebar', () => {
196+
test('filters out children that are descendants of another sibling', () => {
197+
// Simulate the sidebarTree descendant filtering logic.
198+
// When a bespoke landing page lists both individual articles and their
199+
// parent group as children, the individual articles should be filtered out
200+
// from the sidebar (they appear nested under their parent group instead).
201+
const childPages = [
202+
{
203+
href: '/en/get-started/copilot/add-custom-instructions',
204+
title: 'Add custom instructions',
205+
},
206+
{ href: '/en/get-started/copilot/create-skills', title: 'Create skills' },
207+
{ href: '/en/get-started/copilot', title: 'Copilot' },
208+
{ href: '/en/get-started/actions', title: 'Actions' },
209+
]
210+
211+
const siblingHrefs = childPages.map((c) => c.href)
212+
const dedupedChildPages = childPages.filter(
213+
(child) => !siblingHrefs.some((sh) => sh !== child.href && child.href.startsWith(`${sh}/`)),
214+
)
215+
216+
// The two individual articles under /copilot/ should be removed
217+
expect(dedupedChildPages).toHaveLength(2)
218+
expect(dedupedChildPages.map((c) => c.title)).toEqual(['Copilot', 'Actions'])
219+
})
220+
221+
test('does not filter children that are not descendants of any sibling', () => {
222+
// Normal case: no overlapping paths, nothing should be filtered
223+
const childPages = [
224+
{ href: '/en/get-started/copilot', title: 'Copilot' },
225+
{ href: '/en/get-started/actions', title: 'Actions' },
226+
{ href: '/en/get-started/packages', title: 'Packages' },
227+
]
228+
229+
const siblingHrefs = childPages.map((c) => c.href)
230+
const dedupedChildPages = childPages.filter(
231+
(child) => !siblingHrefs.some((sh) => sh !== child.href && child.href.startsWith(`${sh}/`)),
232+
)
233+
234+
expect(dedupedChildPages).toHaveLength(3)
235+
})
236+
237+
test('handles multiple overlapping groups correctly', () => {
238+
// Multiple groups each with their own individual articles listed
239+
const childPages = [
240+
{ href: '/en/get-started/copilot/article-a', title: 'Article A' },
241+
{ href: '/en/get-started/copilot', title: 'Copilot' },
242+
{ href: '/en/get-started/actions/article-b', title: 'Article B' },
243+
{ href: '/en/get-started/actions', title: 'Actions' },
244+
]
245+
246+
const siblingHrefs = childPages.map((c) => c.href)
247+
const dedupedChildPages = childPages.filter(
248+
(child) => !siblingHrefs.some((sh) => sh !== child.href && child.href.startsWith(`${sh}/`)),
249+
)
250+
251+
expect(dedupedChildPages).toHaveLength(2)
252+
expect(dedupedChildPages.map((c) => c.title)).toEqual(['Copilot', 'Actions'])
253+
})
254+
})
194255
})

src/landings/components/shared/LandingArticleGridWithFilter.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,18 @@ const flattenArticlesRecursive = (articles: (TocItem | ChildTocItem)[]): Article
4040
return flattened
4141
}
4242

43-
// Wrapper function that flattens and sorts alphabetically by title (only once)
43+
// Wrapper function that flattens, deduplicates, and sorts alphabetically by title (only once)
4444
const flattenArticles = (articles: (TocItem | ChildTocItem)[]): ArticleCardItems => {
4545
const flattened = flattenArticlesRecursive(articles)
46-
return flattened.sort((a, b) => a.title.localeCompare(b.title))
46+
// Deduplicate articles by fullPath - needed when a page lists both individual
47+
// articles and their parent group as children (e.g., bespoke landing pages)
48+
const seen = new Set<string>()
49+
const deduped = flattened.filter((article) => {
50+
if (seen.has(article.fullPath)) return false
51+
seen.add(article.fullPath)
52+
return true
53+
})
54+
return deduped.sort((a, b) => a.title.localeCompare(b.title))
4755
}
4856

4957
// Hook to get current articles per page based on screen size

0 commit comments

Comments
 (0)