Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ DWithEase Discover is the DWithEase news engine for ecommerce and Salesforce B2C
## Public links

- [Discover](https://discover.dwithease.com/)
- [Live v2 feed](https://discover.dwithease.com/feed-live.json)
- [Development v2 feed](https://discover.dwithease.com/feed-dev.json)
- [Live feed](https://discover.dwithease.com/feed-live.json)
- [Development feed](https://discover.dwithease.com/feed-dev.json)
- [Interactive API reference](https://discover.dwithease.com/schema.html)
- [JSON Schema](https://discover.dwithease.com/feed.schema.json)
- [OpenAPI document](https://discover.dwithease.com/openapi.json)
Expand All @@ -17,13 +17,12 @@ DWithEase Discover is the DWithEase news engine for ecommerce and Salesforce B2C

See [SOURCES.md](SOURCES.md) for the full source list and curation rules.

## Feed v2
## Feed format

Both public endpoints use the v2 JSON format:
Both public endpoints use the current JSON format:

```json
{
"version": 2,
"locale": "en",
"updatedAt": "2026-09-01T12:00:00Z",
"items": []
Expand Down
2 changes: 1 addition & 1 deletion SOURCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ This file records the inputs for `feed-dev.json`.
- 4 promotion items.
- 42 items in total.

`feed-live.json` remains a valid empty v2 feed during dev curation.
`feed-live.json` remains a valid empty feed during dev curation.
13 changes: 6 additions & 7 deletions assets/feed-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function validImage(image) {
|| (typeof image.src === 'string' && image.src.startsWith('assets/'));
}

function validV2Item(item) {
function validFeedItem(item) {
if (!item || !['article', 'news', 'promotion'].includes(item.type)) return false;
const requiredKeys = [
'id',
Expand Down Expand Up @@ -111,13 +111,12 @@ function validV2Item(item) {
return campaign.placements.every((placement) => PRODUCT_PLACEMENTS.has(placement));
}

function validV2Feed(raw) {
if (!exactObject(raw, ['version', 'locale', 'updatedAt', 'items'])) return false;
if (raw.version !== 2
|| typeof raw.locale !== 'string'
function validFeed(raw) {
if (!exactObject(raw, ['locale', 'updatedAt', 'items'])) return false;
if (typeof raw.locale !== 'string'
|| !LOCALE_PATTERN.test(raw.locale)
|| !dateTime(raw.updatedAt)) return false;
if (!Array.isArray(raw.items) || !raw.items.every((item) => validV2Item(item))) return false;
if (!Array.isArray(raw.items) || !raw.items.every((item) => validFeedItem(item))) return false;
return new Set(raw.items.map(({ id }) => id)).size === raw.items.length;
}

Expand Down Expand Up @@ -159,7 +158,7 @@ export function selectFeedName(search = '') {
}

export function buildCatalog(raw, now = Date.now()) {
const items = validV2Feed(raw) ? raw.items : [];
const items = validFeed(raw) ? raw.items : [];
const promotions = items.filter((item) => item.type === 'promotion'
&& item.campaign.placements.includes('discover')
&& Date.parse(item.campaign.startsAt) <= now
Expand Down
1 change: 0 additions & 1 deletion feed-dev.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"version": 2,
"locale": "en",
"updatedAt": "2026-09-01T12:50:31.306Z",
"items": [
Expand Down
1 change: 0 additions & 1 deletion feed-live.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"version": 2,
"locale": "en",
"updatedAt": "2026-09-01T12:50:31.306Z",
"items": []
Expand Down
5 changes: 0 additions & 5 deletions feed.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 2
},
"locale": {
"type": "string",
"pattern": "^[a-z]{2}(?:-[A-Z]{2})?(?![\\s\\S])"
Expand Down Expand Up @@ -317,7 +313,6 @@
}
},
"required": [
"version",
"locale",
"updatedAt",
"items"
Expand Down
9 changes: 2 additions & 7 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"openapi": "3.1.1",
"info": {
"title": "DWithEase Discover Feed API",
"version": "2.0.0",
"version": "1.0.0",
"description": "Public commerce articles, news, and DWithEase product promotions. The Zod runtime also rejects duplicate item IDs and campaign end dates that do not follow their start dates."
},
"tags": [
Expand Down Expand Up @@ -61,13 +61,9 @@
"schemas": {
"DiscoverFeed": {
"title": "Discover Feed",
"description": "The complete version 2 Discover document.",
"description": "The complete Discover feed document.",
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 2
},
"locale": {
"type": "string",
"pattern": "^[a-z]{2}(?:-[A-Z]{2})?(?![\\s\\S])"
Expand Down Expand Up @@ -379,7 +375,6 @@
}
},
"required": [
"version",
"locale",
"updatedAt",
"items"
Expand Down
1 change: 0 additions & 1 deletion scripts/feed-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ export const FeedItemSchema = z.discriminatedUnion('type', [
]);

export const FeedSchema = z.strictObject({
version: z.literal(2),
locale: z.string().regex(new RegExp(`^[a-z]{2}(?:-[A-Z]{2})?${STRICT_END}`)),
updatedAt: DateTimeSchema,
items: z.array(FeedItemSchema),
Expand Down
4 changes: 2 additions & 2 deletions scripts/write-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const document = {
openapi: '3.1.1',
info: {
title: 'DWithEase Discover Feed API',
version: '2.0.0',
version: '1.0.0',
description: 'Public commerce articles, news, and DWithEase product promotions. The Zod runtime also rejects duplicate item IDs and campaign end dates that do not follow their start dates.',
},
tags: [
Expand Down Expand Up @@ -61,7 +61,7 @@ const document = {
schemas: {
DiscoverFeed: {
title: 'Discover Feed',
description: 'The complete version 2 Discover document.',
description: 'The complete Discover feed document.',
...discoverFeedSchema,
},
},
Expand Down
1 change: 0 additions & 1 deletion test/browser-page.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ const COMMON_ITEM = {
},
};
const TEST_FEED = {
version: 2,
locale: 'en',
updatedAt: '2026-09-01T00:00:00Z',
items: [
Expand Down
16 changes: 7 additions & 9 deletions test/discover-page.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function createRoot() {
return document.getElementById('discover-content');
}

const V2_PROMOTION = {
const PROMOTION = {
id: 'catalogspark-2026',
type: 'promotion',
title: 'Make product data ready',
Expand All @@ -39,7 +39,7 @@ const V2_PROMOTION = {
},
};

test('renders v2 promotions and editorial items with useful metadata', () => {
test('renders promotions and editorial items with useful metadata', () => {
const root = createRoot();
const unsafeTitle = '<img src=x onerror=alert(1)>';
const article = {
Expand All @@ -64,7 +64,7 @@ test('renders v2 promotions and editorial items with useful metadata', () => {
{ icon: 'https://discover.example/assets/sources/example.svg' },
]]);
renderCatalog(root, {
promotions: [V2_PROMOTION],
promotions: [PROMOTION],
editorial: [article],
}, sources);

Expand Down Expand Up @@ -118,7 +118,7 @@ test('does not render actions for raw non-RFC URLs', () => {
'https://catalogspark.com:65536/audit',
]) {
renderCatalog(root, {
promotions: [{ ...V2_PROMOTION, url }],
promotions: [{ ...PROMOTION, url }],
editorial: [],
});

Expand All @@ -138,7 +138,7 @@ test('renders a working retry action after a feed error', () => {
assert.equal(retries, 1);
});

test('loads only the selected v2 feed', async () => {
test('loads only the selected feed', async () => {
const root = createRoot();
const requests = [];
const fetchImpl = async (url) => {
Expand All @@ -159,10 +159,9 @@ test('loads only the selected v2 feed', async () => {
return {
ok: true,
json: async () => ({
version: 2,
locale: 'en',
updatedAt: '2026-09-01T00:00:00Z',
items: [V2_PROMOTION],
items: [PROMOTION],
}),
};
};
Expand Down Expand Up @@ -191,10 +190,9 @@ test('keeps feed text when the source registry fails', async () => {
return {
ok: true,
json: async () => ({
version: 2,
locale: 'en',
updatedAt: '2026-09-01T00:00:00Z',
items: [V2_PROMOTION],
items: [PROMOTION],
}),
};
};
Expand Down
31 changes: 12 additions & 19 deletions test/feed-model.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { validateFeed } from '../scripts/feed-schema.ts';

const NOW = Date.parse('2026-09-10T12:00:00Z');

const V2_PROMOTION = {
const PROMOTION = {
id: 'catalogspark-2026',
type: 'promotion',
title: 'Make product data ready',
Expand Down Expand Up @@ -40,7 +40,7 @@ test('selects the live feed unless the query asks for the dev feed', () => {
assert.equal(selectFeedName('?feed=other'), 'live');
});

test('builds the v2 catalog with schema-valid offsets and sorted editorial items', () => {
test('builds the catalog with schema-valid offsets and sorted editorial items', () => {
const olderArticle = {
id: 'older-article',
type: 'article',
Expand All @@ -67,30 +67,29 @@ test('builds the v2 catalog with schema-valid offsets and sorted editorial items
};

const catalog = buildCatalog({
version: 2,
locale: 'en',
updatedAt: '2026-09-01T00:00:00Z',
items: [olderArticle, V2_PROMOTION, newerNews],
items: [olderArticle, PROMOTION, newerNews],
}, NOW);

assert.deepEqual(catalog.promotions.map(({ id }) => id), ['catalogspark-2026']);
assert.deepEqual(catalog.editorial.map(({ id }) => id), ['newer-news', 'older-article']);
});

test('requires the complete v2 document before building a catalog', () => {
test('requires the complete feed document before building a catalog', () => {
const validFeed = {
version: 2,
locale: 'en',
updatedAt: '2026-09-01T00:00:00Z',
items: [V2_PROMOTION],
items: [PROMOTION],
};
const { updatedAt, ...missingUpdatedAt } = validFeed;
const invalidFeeds = [
missingUpdatedAt,
{ ...validFeed, updatedAt: 'not-a-date' },
{ ...validFeed, extra: true },
{ ...validFeed, items: [{ ...V2_PROMOTION, extra: true }] },
{ ...validFeed, items: [V2_PROMOTION, { ...V2_PROMOTION }] },
{ ...validFeed, version: 2 },
{ ...validFeed, items: [{ ...PROMOTION, extra: true }] },
{ ...validFeed, items: [PROMOTION, { ...PROMOTION }] },
];

for (const feed of invalidFeeds) {
Expand All @@ -99,7 +98,7 @@ test('requires the complete v2 document before building a catalog', () => {
}
});

test('enforces raw v2 text limits in Zod and the browser catalog', () => {
test('enforces raw text limits in Zod and the browser catalog', () => {
const article = {
id: 'raw-text-limits',
type: 'article',
Expand All @@ -121,7 +120,6 @@ test('enforces raw v2 text limits in Zod and the browser catalog', () => {
},
};
const validFeed = {
version: 2,
locale: 'en',
updatedAt: '2026-09-01T00:00:00Z',
items: [article],
Expand Down Expand Up @@ -170,7 +168,6 @@ test('counts Unicode code points for browser text limits', () => {
},
};
const feed = {
version: 2,
locale: 'en',
updatedAt: '2026-09-01T00:00:00Z',
items: [article],
Expand Down Expand Up @@ -205,7 +202,6 @@ test('requires raw RFC 3986 lowercase HTTPS URLs across feed validators', () =>
},
};
const validFeed = {
version: 2,
locale: 'en',
updatedAt: '2026-09-01T00:00:00Z',
items: [article],
Expand Down Expand Up @@ -299,7 +295,6 @@ test('keeps web host validation aligned across feed contracts', () => {

for (const [url, accepted] of cases) {
const feed = {
version: 2,
locale: 'en',
updatedAt: '2026-09-01T00:00:00Z',
items: [{ ...article, url }],
Expand Down Expand Up @@ -330,7 +325,6 @@ test('rejects final line breaks across regex-backed public fields', () => {
},
};
const validFeed = {
version: 2,
locale: 'en',
updatedAt: '2026-09-01T00:00:00Z',
items: [article],
Expand Down Expand Up @@ -398,16 +392,15 @@ test('renders schema-valid reserved-word slugs', () => {
},
};
const promotion = {
...V2_PROMOTION,
...PROMOTION,
id: 'prototype',
tags: ['constructor'],
campaign: {
...V2_PROMOTION.campaign,
...PROMOTION.campaign,
id: 'constructor',
},
};
const feed = {
version: 2,
locale: 'en',
updatedAt: '2026-09-01T00:00:00Z',
items: [article, promotion],
Expand All @@ -419,7 +412,7 @@ test('renders schema-valid reserved-word slugs', () => {
assert.deepEqual(catalog.promotions.map(({ id }) => id), ['prototype']);
});

test('returns an empty v2 catalog for invalid payloads', () => {
test('returns an empty catalog for invalid payloads', () => {
assert.deepEqual(buildCatalog({ items: [] }, NOW), {
promotions: [],
editorial: [],
Expand Down
Loading
Loading