Skip to content

Commit 3f9cd87

Browse files
authored
Keep serialized chapters out of standalone homepage columns
* Add published-series-aware standalone homepage column selector * Exclude all serialized chapters from homepage standalone column listing * Cover series card headline repeats and standalone column selection in both languages * Correct regex syntax in bilingual series-column regression tests
1 parent 26fb158 commit 3f9cd87

3 files changed

Lines changed: 85 additions & 4 deletions

File tree

‎scripts/test-home-duplicates.mjs‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ try {
2222
const { selectHotIssueCards } = await server.ssrLoadModule("/src/data/hotIssueSelection.ts");
2323
const { selectNewsTrackingCards } = await server.ssrLoadModule("/src/data/newsTracking.ts");
2424
const { selectLatestCivicWatchItems } = await server.ssrLoadModule("/src/data/civicWatchFeed.ts");
25+
const { getEditorialSeries } = await server.ssrLoadModule("/src/data/editorialSeries.ts");
26+
const { getColumnsNewestFirst } = await server.ssrLoadModule("/src/data/columns.ts");
27+
const { selectHomeStandaloneColumns } = await server.ssrLoadModule("/src/data/homeColumnSelection.ts");
28+
2529

2630
assert.equal(canonicalHomePath("/en/columns/example/?utm_source=test#top"), "/columns/example");
2731
assert.equal(getHomeTopic("/en/news/olympic-park-protest-115-days/"),
@@ -56,6 +60,31 @@ try {
5660
assert.deepEqual(selectLatestCivicWatchItems(civic, new Set(["/en/news/olympic-park-protest-115-days/"]), 8).map((item) => item.to),
5761
[fixtures[1].to, fixtures[3].to], "Civic-watch exclusions must canonicalize aliases");
5862

63+
// A new published chapter automatically leaves the standalone homepage
64+
// column desk even if it is not the most recently published series chapter.
65+
const standaloneFixtures = [
66+
{ slug: "series-chapter-1" },
67+
{ slug: "series-chapter-2" },
68+
{ slug: "standalone-1" },
69+
{ slug: "standalone-2" },
70+
{ slug: "standalone-3" },
71+
];
72+
const serializedFixtures = [{ chapters: [
73+
{ path: "/columns/series-chapter-1" },
74+
{ path: "/en/columns/series-chapter-2/?ref=series" },
75+
] }];
76+
assert.deepEqual(
77+
selectHomeStandaloneColumns(standaloneFixtures, serializedFixtures, new Set(), 2).map((c) => c.slug),
78+
["standalone-1", "standalone-2"],
79+
"Published series chapters must never take slots in the homepage column desk",
80+
);
81+
assert.deepEqual(
82+
selectHomeStandaloneColumns(standaloneFixtures, serializedFixtures, new Set([getHomeTopic("/columns/standalone-1")]), 2).map((c) => c.slug),
83+
["standalone-2", "standalone-3"],
84+
"Excluded chapters and claimed articles must be skipped before limiting",
85+
);
86+
assert.equal(standaloneFixtures.length, 5, "Filtering cannot delete published column records");
87+
5988
// The carousel intentionally adds off-screen DOM clones for wraparound;
6089
// these are one desk, not repeat placements. Deduplicate within a section,
6190
// then reject any article identity found in another homepage section.
@@ -68,6 +97,30 @@ try {
6897
React.createElement(LanguageProvider, null, React.createElement(Home))));
6998
const sections = [...html.matchAll(/<section\b[^>]*>[\s\S]*?<\/section>/g)];
7099
assert(sections.length >= 6, "Homepage sections must render");
100+
101+
// Series cards display an actual chapter headline without linking directly
102+
// to its article. Checking article hrefs alone misses that visible repeat.
103+
const language = route === "/" ? "ko" : "en";
104+
const series = getEditorialSeries(language);
105+
const serializedPaths = new Set(series.flatMap((item) =>
106+
item.chapters.map((chapter) => canonicalHomePath(chapter.path)),
107+
));
108+
assert(serializedPaths.size > 0, `${route}: expected published editorial series chapters`);
109+
assert(getColumnsNewestFirst().some((column) =>
110+
serializedPaths.has(`/columns/${column.slug}`)),
111+
`${route}: regression scenario must contain series chapters in the full columns archive`);
112+
const columnsSection = sections.find((section) => section[0].includes('>COLUMNS</p>'));
113+
assert(columnsSection, `${route}: standalone column desk must exist`);
114+
const renderedColumns = [...columnsSection[0].matchAll(/<a\b[^>]*href="([^"]+)"/g)]
115+
.map((match) => canonicalHomePath(match[1]))
116+
.filter((path) => /^\/columns\/[^/]+$/.test(path));
117+
assert(renderedColumns.length >= 3, `${route}: excluding series must preserve a useful standalone column desk`);
118+
assert(renderedColumns.every((path) => !serializedPaths.has(path)),
119+
`${route}: serialized article repeated in standalone columns: ${renderedColumns.filter((path) => serializedPaths.has(path)).join(", ")}`);
120+
const seriesSection = sections.find((section) => section[0].includes('id="editorial-series-title"'));
121+
assert(seriesSection && seriesSection[0].includes('href="/series/'),
122+
`${route}: editorial series navigation must remain available`);
123+
71124
const seen = new Map();
72125
let checked = 0;
73126
for (const [sectionIndex, match] of sections.entries()) {

‎src/data/homeColumnSelection.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { EditorialSeries } from "./editorialSeries";
2+
import { canonicalHomePath, claimHomeStories } from "./homeTopics";
3+
4+
// A serialized article belongs to the Editorial Series homepage desk, even
5+
// when it is not the newest chapter shown on the series card. Keep it in the
6+
// full /columns archive; only the homepage's standalone column desk excludes it.
7+
// Derive this at render time from the published series registry so every new
8+
// approved chapter is protected automatically, without maintaining a slug list.
9+
export function selectHomeStandaloneColumns<T extends { slug: string }>(
10+
columns: readonly T[],
11+
series: readonly Pick<EditorialSeries, "chapters">[],
12+
claimedTopics: Set<string>,
13+
limit = 5,
14+
): T[] {
15+
const seriesArticlePaths = new Set(series.flatMap((item) =>
16+
item.chapters.map((chapter) => canonicalHomePath(chapter.path)),
17+
));
18+
return claimHomeStories(
19+
columns.filter((column) =>
20+
!seriesArticlePaths.has(canonicalHomePath(`/columns/${column.slug}`)),
21+
),
22+
(column) => `/columns/${column.slug}`,
23+
claimedTopics,
24+
limit,
25+
);
26+
}

‎src/pages/Home.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { getTaxCommentaryEdition, taxCommentaries } from "../data/taxCommentarie
1818
import { getFeaturedContentCandidates } from "../data/featuredContent";
1919
import { getLatestArticles } from "../data/latestArticles";
2020
import { getEditorialSeries } from "../data/editorialSeries";
21+
import { selectHomeStandaloneColumns } from "../data/homeColumnSelection";
2122
import { claimHomeStory, claimHomeStories, getHomeTopic } from "../data/homeTopics";
2223
import { seedLanguageTerms } from "../data/seedLanguageTerms";
2324
import { topicTaxonomy } from "../data/topicTaxonomy";
@@ -314,11 +315,12 @@ export default function Home() {
314315
.sort((a, b) => newestInSection({ path: a.to, date: a.date }, { path: b.to, date: b.date }));
315316

316317
const commentaryItems = claimHomeStories(commentaryCandidates, (item) => item.to, claimedHomePaths, 3);
317-
// The same shared claim registry powers every homepage article desk.
318-
// Only unclaimed articles fill the column slots, with no hardcoded exclusions.
319-
const journalColumns = claimHomeStories(
318+
// Every published serialized chapter is reserved for the Editorial Series.
319+
// Standalone columns fill the remaining desk slots; the full /columns archive
320+
// continues to include all approved chapters and unrelated articles.
321+
const journalColumns = selectHomeStandaloneColumns(
320322
allJournalColumns,
321-
(column) => `/columns/${column.slug}`,
323+
editorialSeries,
322324
claimedHomePaths,
323325
5,
324326
).map((column) => ({ ...column, to: `/columns/${column.slug}` }));

0 commit comments

Comments
 (0)